serverless-ircd 0.8.0 → 0.10.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 (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +177 -52
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +267 -92
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -6,86 +6,257 @@
6
6
  */
7
7
 
8
8
  import { describe, expect, it } from 'vitest';
9
- import { parseSaslAccountsEnv, wsFrameModeFromTags } from '../src/connection-do';
9
+ import {
10
+ NO_OP_DISCONNECT,
11
+ NO_OP_SEND,
12
+ buildOptionalActorFields,
13
+ chooseMotdProvider,
14
+ extractMtlsSubject,
15
+ wsFrameModeFromTags,
16
+ } from '../src/connection-do';
10
17
 
11
- describe('parseSaslAccountsEnv', () => {
12
- it('returns an empty array for undefined input', () => {
13
- expect(parseSaslAccountsEnv(undefined)).toEqual([]);
18
+ describe('wsFrameModeFromTags', () => {
19
+ it("returns 'legacy' when no tags are present", () => {
20
+ expect(wsFrameModeFromTags([])).toBe('legacy');
21
+ });
22
+
23
+ it("returns 'legacy' when no recognized tag matches", () => {
24
+ // Unknown tags (e.g. from an older deployment's tag scheme) fall
25
+ // back to the legacy-tolerant mode.
26
+ expect(wsFrameModeFromTags(['unknown:tag', 'another'])).toBe('legacy');
27
+ });
28
+
29
+ it("returns 'spec-text' when the spec-text tag is present", () => {
30
+ expect(wsFrameModeFromTags(['ws:spec-text'])).toBe('spec-text');
31
+ });
32
+
33
+ it("returns 'spec-binary' when the spec-binary tag is present", () => {
34
+ expect(wsFrameModeFromTags(['ws:spec-binary'])).toBe('spec-binary');
35
+ });
36
+
37
+ it("returns 'legacy' when the legacy tag is present", () => {
38
+ expect(wsFrameModeFromTags(['ws:legacy'])).toBe('legacy');
39
+ });
40
+
41
+ it('picks the first recognized tag when multiple are present', () => {
42
+ // The scan is order-sensitive; the first matching tag wins.
43
+ expect(wsFrameModeFromTags(['ws:spec-text', 'ws:legacy'])).toBe('spec-text');
44
+ });
45
+ });
46
+
47
+ // ============================================================================
48
+ // PassthroughChannelAccess.refreshChannel
49
+ // ============================================================================
50
+ //
51
+ // refreshChannel pulls the authoritative snapshot from the ChannelDO and
52
+ // rebuilds (or updates) the local cache entry. The zero-members branch is
53
+ // the post-deploy state: the ChannelDO persisted a topic across the
54
+ // deploy but its roster is empty, so the local cache must still absorb
55
+ // the topic/modes/banMasks or the JOIN reducer runs against a topic-less
56
+ // state and never emits 332/333.
57
+ // ============================================================================
58
+
59
+ import { PassthroughChannelAccess } from '../src/connection-do';
60
+ import type { Env } from '../src/env';
61
+
62
+ const EMPTY_MODES = {
63
+ inviteOnly: false,
64
+ topicLock: false,
65
+ noExternal: false,
66
+ moderated: false,
67
+ secret: false,
68
+ private: false,
69
+ registered: false,
70
+ blockUnidentified: false,
71
+ moderatedIdent: false,
72
+ };
73
+
74
+ /** Builds a fake Env whose CHANNEL_DO returns `snapshot` from getChannelSnapshot. */
75
+ function makeEnvWithSnapshot(snapshot: unknown): Env {
76
+ const fakeChannelDo = {
77
+ idFromName: (key: string) => `id-${key}`,
78
+ get: () => ({ getChannelSnapshot: async () => snapshot }),
79
+ };
80
+ return { CHANNEL_DO: fakeChannelDo } as unknown as Env;
81
+ }
82
+
83
+ describe('PassthroughChannelAccess.refreshChannel', () => {
84
+ it('propagates topic/modes/banMasks onto the cached entry when the snapshot reports zero members', async () => {
85
+ const snap = {
86
+ modes: { ...EMPTY_MODES, topicLock: true },
87
+ banMasks: ['bad!*@*'],
88
+ topic: { text: 'survived deploy', setter: 'alice!alice@example.com', setAt: 1234 },
89
+ members: [],
90
+ pendingInvites: [],
91
+ createdAt: 0,
92
+ };
93
+ const env = makeEnvWithSnapshot(snap);
94
+ const access = new PassthroughChannelAccess(0, env);
95
+ const chan = access.getOrCreateChannel('#foo');
96
+ expect(chan.topic).toBeUndefined();
97
+
98
+ await access.refreshChannel('#foo');
99
+
100
+ expect(chan.topic?.text).toBe('survived deploy');
101
+ expect(chan.topic?.setter).toBe('alice!alice@example.com');
102
+ expect(chan.topic?.setAt).toBe(1234);
103
+ expect(chan.modes.topicLock).toBe(true);
104
+ expect(chan.banMasks.has('bad!*@*')).toBe(true);
14
105
  });
15
106
 
16
- it('returns an empty array for an empty string', () => {
17
- expect(parseSaslAccountsEnv('')).toEqual([]);
107
+ it('leaves the cached topic unset when a zero-members snapshot carries no topic', async () => {
108
+ const snap = {
109
+ modes: { ...EMPTY_MODES },
110
+ banMasks: [],
111
+ members: [],
112
+ pendingInvites: [],
113
+ createdAt: 0,
114
+ };
115
+ const env = makeEnvWithSnapshot(snap);
116
+ const access = new PassthroughChannelAccess(0, env);
117
+ const chan = access.getOrCreateChannel('#foo');
118
+
119
+ await access.refreshChannel('#foo');
120
+
121
+ expect(chan.topic).toBeUndefined();
122
+ });
123
+
124
+ it('does not seed a cache entry when the snapshot reports zero members and none exists', async () => {
125
+ const snap = {
126
+ modes: { ...EMPTY_MODES },
127
+ banMasks: [],
128
+ topic: { text: 'x', setter: 's', setAt: 0 },
129
+ members: [],
130
+ pendingInvites: [],
131
+ createdAt: 0,
132
+ };
133
+ const env = makeEnvWithSnapshot(snap);
134
+ const access = new PassthroughChannelAccess(0, env);
135
+
136
+ await access.refreshChannel('#never');
137
+
138
+ // No cache entry was created: getOrCreateChannel now seeds a fresh one
139
+ // with no topic (the zero-members refresh must not seed a new entry).
140
+ const chan = access.getOrCreateChannel('#never');
141
+ expect(chan.topic).toBeUndefined();
142
+ });
143
+
144
+ it('rebuilds the cached entry from a snapshot with members', async () => {
145
+ const snap = {
146
+ modes: { ...EMPTY_MODES, secret: true },
147
+ banMasks: [],
148
+ topic: { text: 'hi', setter: 's', setAt: 9 },
149
+ members: [{ conn: 'c0', nick: 'alice', op: true, voice: false }],
150
+ pendingInvites: [],
151
+ createdAt: 7,
152
+ };
153
+ const env = makeEnvWithSnapshot(snap);
154
+ const access = new PassthroughChannelAccess(0, env);
155
+
156
+ await access.refreshChannel('#foo');
157
+
158
+ const chan = access.getOrCreateChannel('#foo');
159
+ expect(chan.topic?.text).toBe('hi');
160
+ expect(chan.modes.secret).toBe(true);
161
+ expect(chan.members.size).toBe(1);
162
+ expect(chan.members.get('c0')?.nick).toBe('alice');
18
163
  });
19
164
 
20
- it('parses a single well-formed entry', () => {
21
- expect(parseSaslAccountsEnv('alice:secret')).toEqual([
22
- { username: 'alice', password: 'secret' },
23
- ]);
165
+ it('is a no-op when the ChannelDO snapshot is null', async () => {
166
+ const env = makeEnvWithSnapshot(null);
167
+ const access = new PassthroughChannelAccess(0, env);
168
+ const chan = access.getOrCreateChannel('#foo');
169
+ chan.topic = { text: 'keep', setter: 's', setAt: 1 };
170
+
171
+ await access.refreshChannel('#foo');
172
+
173
+ expect(chan.topic?.text).toBe('keep');
24
174
  });
175
+ });
176
+
177
+ // ============================================================================
178
+ // chooseMotdProvider — default vs. populated MOTD_LINES branch
179
+ // ============================================================================
25
180
 
26
- it('parses multiple newline-delimited entries', () => {
27
- expect(parseSaslAccountsEnv('alice:secret\nbob:pw')).toEqual([
28
- { username: 'alice', password: 'secret' },
29
- { username: 'bob', password: 'pw' },
30
- ]);
181
+ describe('chooseMotdProvider', () => {
182
+ it('returns the default MOTD when the env value is undefined', () => {
183
+ const provider = chooseMotdProvider(undefined);
184
+ expect(provider.lines()).toEqual(['Welcome to the ServerlessIRCd Cloudflare adapter.']);
31
185
  });
32
186
 
33
- it('skips blank lines', () => {
34
- expect(parseSaslAccountsEnv('alice:secret\n\nbob:pw\n \n')).toEqual([
35
- { username: 'alice', password: 'secret' },
36
- { username: 'bob', password: 'pw' },
37
- ]);
187
+ it('returns the default MOTD when the env value is an empty string', () => {
188
+ const provider = chooseMotdProvider('');
189
+ expect(provider.lines()).toEqual(['Welcome to the ServerlessIRCd Cloudflare adapter.']);
38
190
  });
39
191
 
40
- it('skips lines with no colon separator', () => {
41
- expect(parseSaslAccountsEnv('alice:secret\nnocolonhere\nbob:pw')).toEqual([
42
- { username: 'alice', password: 'secret' },
43
- { username: 'bob', password: 'pw' },
44
- ]);
192
+ it('splits a populated MOTD_LINES on newlines', () => {
193
+ const provider = chooseMotdProvider('Line one\nLine two\nLine three');
194
+ expect(provider.lines()).toEqual(['Line one', 'Line two', 'Line three']);
45
195
  });
196
+ });
197
+
198
+ // ============================================================================
199
+ // buildOptionalActorFields — conditional spread branches
200
+ // ============================================================================
46
201
 
47
- it('skips lines where the colon is at position 0 (empty username)', () => {
48
- expect(parseSaslAccountsEnv(':secret\nalice:valid')).toEqual([
49
- { username: 'alice', password: 'valid' },
50
- ]);
202
+ describe('buildOptionalActorFields', () => {
203
+ it('returns an empty object when every optional is undefined', () => {
204
+ expect(buildOptionalActorFields(undefined, undefined)).toEqual({});
51
205
  });
52
206
 
53
- it('skips lines with an empty password after the colon', () => {
54
- expect(parseSaslAccountsEnv('alice:\nbob:valid')).toEqual([
55
- { username: 'bob', password: 'valid' },
56
- ]);
207
+ it('includes `services` when defined', () => {
208
+ const fake = {} as unknown as Parameters<typeof buildOptionalActorFields>[0];
209
+ expect(buildOptionalActorFields(fake, undefined).services).toBe(fake);
57
210
  });
58
211
 
59
- it('returns an empty array when every line is malformed', () => {
60
- expect(parseSaslAccountsEnv('\n \n:no-user\nuser:')).toEqual([]);
212
+ it('includes `mtlsIdentity` when defined', () => {
213
+ const fake = {} as unknown as Parameters<typeof buildOptionalActorFields>[1];
214
+ expect(buildOptionalActorFields(undefined, fake).mtlsIdentity).toBe(fake);
61
215
  });
62
216
  });
63
217
 
64
- describe('wsFrameModeFromTags', () => {
65
- it("returns 'legacy' when no tags are present", () => {
66
- expect(wsFrameModeFromTags([])).toBe('legacy');
218
+ // ============================================================================
219
+ // NO_OP_SEND / NO_OP_DISCONNECT invoked once to cover the function bodies
220
+ // (the no-socket teardown path constructs these handlers but the actor never
221
+ // invokes send/disconnect during QUIT processing — there is no peer to
222
+ // deliver to and the socket is already gone).
223
+ // ============================================================================
224
+
225
+ describe('NO_OP_SEND / NO_OP_DISCONNECT', () => {
226
+ it('NO_OP_SEND runs without throwing and returns undefined', () => {
227
+ expect(NO_OP_SEND([{ text: 'ignored' }])).toBeUndefined();
67
228
  });
68
229
 
69
- it("returns 'legacy' when no recognized tag matches", () => {
70
- // Unknown tags (e.g. from an older deployment's tag scheme) fall
71
- // back to the legacy-tolerant mode.
72
- expect(wsFrameModeFromTags(['unknown:tag', 'another'])).toBe('legacy');
230
+ it('NO_OP_DISCONNECT runs without throwing and returns undefined', () => {
231
+ expect(NO_OP_DISCONNECT('any reason')).toBeUndefined();
73
232
  });
233
+ });
74
234
 
75
- it("returns 'spec-text' when the spec-text tag is present", () => {
76
- expect(wsFrameModeFromTags(['ws:spec-text'])).toBe('spec-text');
235
+ // ============================================================================
236
+ // extractMtlsSubject — request.cf.tlsClientAuthCertSubject capture
237
+ // ============================================================================
238
+
239
+ describe('extractMtlsSubject', () => {
240
+ it('returns the subject when cf.tlsClientAuthCertSubject is a string', () => {
241
+ // The CF edge injects request.cf on every proxied request. We can't
242
+ // mutate Request.cf in workerd tests (it's read-only), so the
243
+ // upgrade-time capture is exercised via this pure seam.
244
+ const fake = { cf: { tlsClientAuthCertSubject: 'CN=alice' } } as unknown as Request;
245
+ expect(extractMtlsSubject(fake)).toBe('CN=alice');
77
246
  });
78
247
 
79
- it("returns 'spec-binary' when the spec-binary tag is present", () => {
80
- expect(wsFrameModeFromTags(['ws:spec-binary'])).toBe('spec-binary');
248
+ it('returns undefined when cf is absent', () => {
249
+ const fake = { cf: undefined } as unknown as Request;
250
+ expect(extractMtlsSubject(fake)).toBeUndefined();
81
251
  });
82
252
 
83
- it("returns 'legacy' when the legacy tag is present", () => {
84
- expect(wsFrameModeFromTags(['ws:legacy'])).toBe('legacy');
253
+ it('returns undefined when cf is present but lacks tlsClientAuthCertSubject', () => {
254
+ const fake = { cf: { country: 'US' } } as unknown as Request;
255
+ expect(extractMtlsSubject(fake)).toBeUndefined();
85
256
  });
86
257
 
87
- it('picks the first recognized tag when multiple are present', () => {
88
- // The scan is order-sensitive; the first matching tag wins.
89
- expect(wsFrameModeFromTags(['ws:spec-text', 'ws:legacy'])).toBe('spec-text');
258
+ it('returns undefined when tlsClientAuthCertSubject is not a string', () => {
259
+ const fake = { cf: { tlsClientAuthCertSubject: 42 } } as unknown as Request;
260
+ expect(extractMtlsSubject(fake)).toBeUndefined();
90
261
  });
91
262
  });
@@ -1,10 +1,9 @@
1
1
  /**
2
- * End-to-end SASL PLAIN through ConnectionDO with D1-backed accounts.
2
+ * End-to-end SASL PLAIN through ConnectionDO with D1-backed services.
3
3
  *
4
- * Verifies the full precedence matrix:
5
- * - D1 has rows → table is authoritative (900/903 for seeded account,
6
- * 904 for the env-seed account which the table shadows).
7
- * - D1 empty → the `SASL_ACCOUNTS` env-var seed still works (fallback).
4
+ * Verifies the services-store credential path: an account seeded into the
5
+ * `nickserv_accounts` table (via `ServicesStore.registerNick`) verifies
6
+ * end-to-end through `AUTHENTICATE PLAIN`.
8
7
  *
9
8
  * Each test file gets its own isolated D1 database from miniflare, so the
10
9
  * table state set up here does not leak into `connection-do.test.ts`.
@@ -12,7 +11,7 @@
12
11
 
13
12
  import { env } from 'cloudflare:test';
14
13
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15
- import { CREATE_ACCOUNTS_TABLE_SQL, putAccountCredential } from '../src/d1-account-store';
14
+ import { CREATE_SERVICES_TABLES_SQL } from '../src/d1-services-store';
16
15
 
17
16
  declare global {
18
17
  namespace Cloudflare {
@@ -107,16 +106,56 @@ async function authenticatePlain(
107
106
  client.send(`AUTHENTICATE ${plainB64(authcid, password)}\r\n`);
108
107
  }
109
108
 
110
- describe('ConnectionDO — SASL PLAIN with D1-backed accounts', () => {
109
+ /**
110
+ * Seeds a NickServ registration directly into the D1 services table by
111
+ * driving a first connection through `PRIVMSG NickServ :REGISTER`. Returns
112
+ * once the registration NOTICE is observed.
113
+ */
114
+ async function seedViaNickservRegister(
115
+ connId: string,
116
+ nick: string,
117
+ password: string,
118
+ email: string,
119
+ ): Promise<IrcWsClient> {
120
+ const client = await connect(connId);
121
+ client.send(`NICK ${nick}\r\n`);
122
+ client.send(`USER ${nick} 0 * :${nick}\r\n`);
123
+ await client.waitForMessage((l) => l.includes(' 001 '));
124
+ client.send(`PRIVMSG NickServ :REGISTER ${password} ${email}\r\n`);
125
+ await client.waitForMessage((l) => l.includes('NickServ') && l.includes('registered'));
126
+ return client;
127
+ }
128
+
129
+ describe('ConnectionDO — SASL PLAIN with D1-backed services', () => {
111
130
  beforeEach(async () => {
112
- await env.ACCOUNTS_DB.exec(CREATE_ACCOUNTS_TABLE_SQL);
131
+ for (const sql of CREATE_SERVICES_TABLES_SQL) {
132
+ await env.ACCOUNTS_DB.exec(sql);
133
+ }
113
134
  });
114
135
  afterEach(async () => {
115
- await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
136
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
137
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS chanserv_channels');
138
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS chanserv_access');
139
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS chanserv_levels');
140
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS hostserv_vhosts');
141
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS hostserv_pending_vhosts');
142
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS memoserv_memos');
143
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS read_markers');
144
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS operserv_akills');
145
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS operserv_jupes');
116
146
  });
117
147
 
118
- it('completes SASL PLAIN with 900/903 for a D1-seeded account', async () => {
119
- await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
148
+ it('completes SASL PLAIN with 900/903 for a NickServ-registered account', async () => {
149
+ // Seed alice via NickServ REGISTER on a first connection.
150
+ const seeder = await seedViaNickservRegister(
151
+ 'conn-d1-sasl-seed',
152
+ 'd1-alice',
153
+ 'd1-s3cret',
154
+ 'd1@example.com',
155
+ );
156
+ await seeder.close();
157
+
158
+ // A second connection speaking SASL PLAIN against the registered nick.
120
159
  const client = await connect('conn-d1-sasl-1');
121
160
  await authenticatePlain(client, 'd1-alice', 'd1-s3cret');
122
161
  await client.waitForMessage((l) => l.includes(' 900 '));
@@ -125,42 +164,27 @@ describe('ConnectionDO — SASL PLAIN with D1-backed accounts', () => {
125
164
  await client.close();
126
165
  });
127
166
 
128
- it('rejects SASL PLAIN with 904 for a wrong password (D1 account)', async () => {
129
- await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
167
+ it('rejects SASL PLAIN with 904 for a wrong password', async () => {
168
+ const seeder = await seedViaNickservRegister(
169
+ 'conn-d1-sasl-seed2',
170
+ 'd1-bob',
171
+ 'd1-s3cret',
172
+ 'd1@example.com',
173
+ );
174
+ await seeder.close();
175
+
130
176
  const client = await connect('conn-d1-sasl-2');
131
- await authenticatePlain(client, 'd1-alice', 'wrong');
177
+ await authenticatePlain(client, 'd1-bob', 'wrong');
132
178
  const fail = await client.waitForMessage((l) => l.includes(' 904 '));
133
179
  expect(fail).toContain(' 904 ');
134
180
  await client.close();
135
181
  });
136
182
 
137
- it('table shadows the env-seed account (table wins)', async () => {
138
- // SASL_ACCOUNTS env seeds "cf-sasl:s3cret" but D1 has no such row.
139
- await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
183
+ it('rejects SASL PLAIN with 904 for an unknown nick', async () => {
140
184
  const client = await connect('conn-d1-sasl-3');
141
- // env-seed account does NOT verify (table is authoritative).
142
- await authenticatePlain(client, 'cf-sasl', 's3cret');
185
+ await authenticatePlain(client, 'nobody', 'whatever');
143
186
  const fail = await client.waitForMessage((l) => l.includes(' 904 '));
144
187
  expect(fail).toContain(' 904 ');
145
188
  await client.close();
146
189
  });
147
190
  });
148
-
149
- describe('ConnectionDO — SASL PLAIN in-memory fallback (D1 empty)', () => {
150
- // The table exists but has zero rows → resolver falls back to SASL_ACCOUNTS.
151
- beforeEach(async () => {
152
- await env.ACCOUNTS_DB.exec(CREATE_ACCOUNTS_TABLE_SQL);
153
- });
154
- afterEach(async () => {
155
- await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
156
- });
157
-
158
- it('falls back to SASL_ACCOUNTS seed when D1 is empty', async () => {
159
- const client = await connect('conn-d1-sasl-fallback');
160
- await authenticatePlain(client, 'cf-sasl', 's3cret');
161
- await client.waitForMessage((l) => l.includes(' 900 '));
162
- const success = await client.waitForMessage((l) => l.includes(' 903 '));
163
- expect(success).toContain(' 903 ');
164
- await client.close();
165
- });
166
- });
@@ -15,6 +15,7 @@ import {
15
15
  CREATE_SERVICES_TABLES_SQL,
16
16
  D1ServicesStore,
17
17
  loadD1ServicesStore,
18
+ safeSelect,
18
19
  } from '../src/d1-services-store';
19
20
 
20
21
  declare global {
@@ -194,6 +195,18 @@ describe('D1ServicesStore — NickServ round-trip across reload', () => {
194
195
  expect(reloaded?.getNickEnforce('alice')).toBe('kill');
195
196
  });
196
197
 
198
+ it('persists SET PASSWORD and round-trips the new credential', async () => {
199
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
200
+ store?.registerNick('alice', 'hunter2', 'a@x');
201
+ store?.setNickPassword('alice', 'newpassword');
202
+ await store?.flush();
203
+
204
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
205
+ // The new password verifies against the reloaded store; the old one no longer does.
206
+ expect(reloaded?.verifyNick('alice', 'newpassword')).toEqual({ ok: true, account: 'alice' });
207
+ expect(reloaded?.verifyNick('alice', 'hunter2').ok).toBe(false);
208
+ });
209
+
197
210
  it('drops a NickServ registration after flush + reload', async () => {
198
211
  const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
199
212
  store?.registerNick('alice', 'pw', 'a@x');
@@ -524,14 +537,18 @@ describe('D1ServicesStore — tolerance', () => {
524
537
  expect(snap.jupes).toEqual([]);
525
538
  });
526
539
 
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
540
+ it('skips rows with NULL fields in the all-TEXT tables (vhosts / pending_vhosts / read_markers)', async () => {
541
+ // Recreate the all-TEXT tables without NOT NULL so a NULL field can
529
542
  // survive — exercises the typeof-string defensive skip for the loaders
530
543
  // whose INTEGER-corruption path is unreachable under SQLite affinity.
531
544
  await env.ACCOUNTS_DB.exec('DROP TABLE hostserv_vhosts');
532
545
  await env.ACCOUNTS_DB.exec(
533
546
  'CREATE TABLE hostserv_vhosts (account_key TEXT PRIMARY KEY, account TEXT, vhost TEXT)',
534
547
  );
548
+ await env.ACCOUNTS_DB.exec('DROP TABLE hostserv_pending_vhosts');
549
+ await env.ACCOUNTS_DB.exec(
550
+ 'CREATE TABLE hostserv_pending_vhosts (account_key TEXT PRIMARY KEY, account TEXT, vhost TEXT)',
551
+ );
535
552
  await env.ACCOUNTS_DB.exec('DROP TABLE read_markers');
536
553
  await env.ACCOUNTS_DB.exec(
537
554
  'CREATE TABLE read_markers (account_key TEXT NOT NULL, channel_key TEXT NOT NULL, account TEXT, channel TEXT, msgid TEXT)',
@@ -541,6 +558,11 @@ describe('D1ServicesStore — tolerance', () => {
541
558
  )
542
559
  .bind('bad#v', null, 'cloak')
543
560
  .run();
561
+ await env.ACCOUNTS_DB.prepare(
562
+ 'INSERT INTO hostserv_pending_vhosts (account_key, account, vhost) VALUES (?, ?, ?)',
563
+ )
564
+ .bind('bad#pv', 'someone', null)
565
+ .run();
544
566
  await env.ACCOUNTS_DB.prepare(
545
567
  'INSERT INTO read_markers (account_key, channel_key, account, channel, msgid) VALUES (?, ?, ?, ?, ?)',
546
568
  )
@@ -552,6 +574,7 @@ describe('D1ServicesStore — tolerance', () => {
552
574
  if (!store) throw new Error('services store failed to load');
553
575
  const snap = store.snapshot();
554
576
  expect(snap.vhosts).toEqual([]);
577
+ expect(snap.pendingVhosts).toEqual([]);
555
578
  expect(snap.readMarkers).toEqual([]);
556
579
  });
557
580
 
@@ -566,6 +589,34 @@ describe('D1ServicesStore — tolerance', () => {
566
589
  });
567
590
  });
568
591
 
592
+ // ---------------------------------------------------------------------------
593
+ // safeSelect — defensive catch block (real D1 bindings don't throw on
594
+ // existing-table queries; the catch arm is unreachable through integration).
595
+ // ---------------------------------------------------------------------------
596
+
597
+ describe('safeSelect — defensive catch block', () => {
598
+ it('returns [] and logs when the underlying D1 query rejects', async () => {
599
+ // A misbehaving binding (transient unavailability, schema drift) would
600
+ // otherwise brick NickServ for the whole connection (the snapshot is
601
+ // cached for life). The catch arm logs to wrangler tail and degrades
602
+ // to an empty result.
603
+ const stub = {
604
+ prepare(): { all(): Promise<never> } {
605
+ return {
606
+ all: async () => {
607
+ throw new Error('simulated D1 outage');
608
+ },
609
+ };
610
+ },
611
+ };
612
+ const result = await safeSelect<{ x: unknown }>(
613
+ stub as unknown as Parameters<typeof safeSelect>[0],
614
+ 'SELECT * FROM nicks',
615
+ );
616
+ expect(result).toEqual([]);
617
+ });
618
+ });
619
+
569
620
  function emptySnapshot(): ServicesSnapshot {
570
621
  return {
571
622
  nicks: [],
@@ -306,4 +306,29 @@ describe('deserialize — migrate integration', () => {
306
306
  expect(state.userModes.wallops).toBe(true);
307
307
  expect(state.caps).toEqual(new Set(['server-time']));
308
308
  });
309
+
310
+ it('coalesces an explicit `secure: null` to false via the ?? fallback', () => {
311
+ // `migrate` only backfills `secure` when the key is absent (=== undefined);
312
+ // an explicit null survives and the `?? false` arm in `deserialize` must
313
+ // catch it. Mirrors a hand-edited storage row where someone wrote null.
314
+ const rec = {
315
+ version: 1,
316
+ id: 'c',
317
+ registration: 'registered' as const,
318
+ capNegotiating: false,
319
+ secure: null,
320
+ caps: [],
321
+ joinedChannels: [],
322
+ userModes: {
323
+ invisible: false,
324
+ oper: false,
325
+ wallops: false,
326
+ serverNotices: false,
327
+ tls: false,
328
+ },
329
+ lastSeen: 0,
330
+ connectedSince: 0,
331
+ };
332
+ expect(deserialize(rec).secure).toBe(false);
333
+ });
309
334
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/in-memory-runtime",
3
- "version": "0.8.0",
3
+ "version": "0.10.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.8.0",
3
+ "version": "0.10.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",