serverless-ircd 0.9.0 → 0.11.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 (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -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 +398 -67
  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 +714 -105
  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/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ CERT_FP_PREFIX,
4
+ canonicalizeCertSubject,
5
+ certFingerprintId,
6
+ certIdentityFromSubject,
7
+ } from '../src/certfp';
8
+
9
+ // ============================================================================
10
+ // canonicalizeCertSubject — RFC 4514-style DN canonicalisation
11
+ // ============================================================================
12
+
13
+ describe('canonicalizeCertSubject', () => {
14
+ it('lowercases the attribute type but preserves the value case', () => {
15
+ expect(canonicalizeCertSubject('CN=Alice')).toBe('cn=Alice');
16
+ });
17
+
18
+ it('collapses repeated whitespace inside a value', () => {
19
+ expect(canonicalizeCertSubject('CN=alice smith')).toBe('cn=alice smith');
20
+ });
21
+
22
+ it('trims whitespace around the type and value', () => {
23
+ expect(canonicalizeCertSubject(' CN = alice ')).toBe('cn=alice');
24
+ });
25
+
26
+ it('sorts RDNs into a stable order regardless of input ordering', () => {
27
+ expect(canonicalizeCertSubject('O=Example Inc,CN=alice')).toBe('cn=alice,o=Example Inc');
28
+ expect(canonicalizeCertSubject('CN=alice,O=Example Inc')).toBe('cn=alice,o=Example Inc');
29
+ });
30
+
31
+ it('sorts AVAs inside a multi-valued RDN regardless of input ordering', () => {
32
+ expect(canonicalizeCertSubject('O=Example+CN=alice')).toBe('cn=alice+o=Example');
33
+ expect(canonicalizeCertSubject('CN=alice+O=Example')).toBe('cn=alice+o=Example');
34
+ });
35
+
36
+ it('collapses two semantically-equal DNs (spacing + ordering differ) to one form', () => {
37
+ const a = canonicalizeCertSubject('CN=alice , O = Example Inc , C=US');
38
+ const b = canonicalizeCertSubject('C=US,O=Example Inc,CN=alice');
39
+ expect(a).toBe(b);
40
+ });
41
+
42
+ it('does not split on an escaped separator', () => {
43
+ expect(canonicalizeCertSubject('CN=a\\,b,O=x')).toBe('cn=a\\,b,o=x');
44
+ });
45
+
46
+ it('skips escaped characters when locating the type/value split', () => {
47
+ expect(canonicalizeCertSubject('CN\\=tag=y')).toBe('cn\\=tag=y');
48
+ });
49
+
50
+ it('does not trim an escaped trailing space from a value', () => {
51
+ expect(canonicalizeCertSubject('CN=alice\\ ')).toBe('cn=alice\\ ');
52
+ });
53
+
54
+ it('keeps an escape pair intact when a pending space precedes it', () => {
55
+ expect(canonicalizeCertSubject('CN=a \\, b')).toBe('cn=a \\, b');
56
+ });
57
+
58
+ it('keeps a trailing dangling backslash verbatim', () => {
59
+ expect(canonicalizeCertSubject('CN=a\\')).toBe('cn=a\\');
60
+ });
61
+
62
+ it('keeps a malformed AVA without an equals sign as trimmed collapsed text', () => {
63
+ expect(canonicalizeCertSubject(' alice smith ')).toBe('alice smith');
64
+ });
65
+
66
+ it('canonicalises an empty DN to the empty string', () => {
67
+ expect(canonicalizeCertSubject('')).toBe('');
68
+ });
69
+ });
70
+
71
+ // ============================================================================
72
+ // certFingerprintId — normalised DER SHA-256 fingerprint binding id
73
+ // ============================================================================
74
+
75
+ describe('certFingerprintId', () => {
76
+ it('prefixes the fingerprint with the binding prefix', () => {
77
+ expect(certFingerprintId('af363dc8')).toBe(`${CERT_FP_PREFIX}af363dc8`);
78
+ });
79
+
80
+ it('lowercases an uppercase hex fingerprint', () => {
81
+ expect(certFingerprintId('AF363DC8')).toBe('fp:af363dc8');
82
+ });
83
+
84
+ it('strips openssl-style colon separators', () => {
85
+ expect(certFingerprintId('AF:36:3D:C8')).toBe('fp:af363dc8');
86
+ });
87
+
88
+ it('trims surrounding whitespace', () => {
89
+ expect(certFingerprintId(' af363dc8 ')).toBe('fp:af363dc8');
90
+ });
91
+ });
92
+
93
+ // ============================================================================
94
+ // certIdentityFromSubject — CertIdentity construction
95
+ // ============================================================================
96
+
97
+ describe('certIdentityFromSubject', () => {
98
+ it('builds an identity with the canonical DN and no fingerprint key', () => {
99
+ expect(certIdentityFromSubject('CN=alice')).toEqual({
100
+ subject: 'CN=alice',
101
+ canonicalDn: 'cn=alice',
102
+ });
103
+ });
104
+
105
+ it('includes the fingerprint when the platform supplied one', () => {
106
+ expect(certIdentityFromSubject('CN=alice', 'af363dc8')).toEqual({
107
+ subject: 'CN=alice',
108
+ canonicalDn: 'cn=alice',
109
+ fingerprint: 'af363dc8',
110
+ });
111
+ });
112
+
113
+ it('carries the canonical DN of a multi-RDN subject', () => {
114
+ const id = certIdentityFromSubject('O=Example,CN=alice');
115
+ expect(id.canonicalDn).toBe('cn=alice,o=Example');
116
+ });
117
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { getLsString } from '../../src/caps/capabilities';
3
+ import { certIdentityFromSubject } from '../../src/certfp';
3
4
  import { capReducer } from '../../src/commands/cap';
4
5
  import { userReducer } from '../../src/commands/registration';
5
6
  import { Effect } from '../../src/effects';
@@ -131,8 +132,12 @@ describe('capReducer — LS', () => {
131
132
 
132
133
  it('advertises sasl=PLAIN,EXTERNAL when an mTLS provider is configured', () => {
133
134
  const state = makeState();
134
- const provider: MtlsIdentityProvider = { getIdentity: () => 'CN=alice' };
135
- const ctx = makeCtx(state, serverConfig, provider);
135
+ state.secure = true;
136
+ const provider: MtlsIdentityProvider = {
137
+ getIdentity: () => certIdentityFromSubject('CN=alice'),
138
+ };
139
+ const cfg: ServerConfig = { ...serverConfig, sasl: { externalEnabled: true } };
140
+ const ctx = makeCtx(state, cfg, provider);
136
141
  const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
137
142
  const send = out.effects[0];
138
143
  expect(send).toBeDefined();
@@ -142,6 +147,75 @@ describe('capReducer — LS', () => {
142
147
  }
143
148
  });
144
149
 
150
+ it('advertises sasl=PLAIN when EXTERNAL is not opted in, even with mTLS on a secure connection', () => {
151
+ // The operator opt-in gate: without sasl.externalEnabled the mechanism
152
+ // is not advertised, so clients never attempt an exchange the server
153
+ // would refuse with 908.
154
+ const state = makeState();
155
+ state.secure = true;
156
+ const provider: MtlsIdentityProvider = {
157
+ getIdentity: () => certIdentityFromSubject('CN=alice'),
158
+ };
159
+ const ctx = makeCtx(state, serverConfig, provider);
160
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
161
+ const send = out.effects[0];
162
+ expect(send).toBeDefined();
163
+ if (send?.tag === 'Send') {
164
+ const body = send.lines[0]?.text ?? '';
165
+ expect(body).toContain('sasl=PLAIN');
166
+ expect(body).not.toContain('sasl=PLAIN,EXTERNAL');
167
+ }
168
+ });
169
+
170
+ it('advertises sasl=PLAIN on an insecure connection even when EXTERNAL is opted in', () => {
171
+ // SASL EXTERNAL is certificate-backed authentication: the client-cert
172
+ // identity only carries meaning over a transport the server trusts
173
+ // (TLS). A plaintext connection never sees the mechanism.
174
+ const state = makeState();
175
+ state.secure = false;
176
+ const provider: MtlsIdentityProvider = {
177
+ getIdentity: () => certIdentityFromSubject('CN=alice'),
178
+ };
179
+ const cfg: ServerConfig = { ...serverConfig, sasl: { externalEnabled: true } };
180
+ const ctx = makeCtx(state, cfg, provider);
181
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
182
+ const send = out.effects[0];
183
+ expect(send).toBeDefined();
184
+ if (send?.tag === 'Send') {
185
+ const body = send.lines[0]?.text ?? '';
186
+ expect(body).toContain('sasl=PLAIN');
187
+ expect(body).not.toContain('sasl=PLAIN,EXTERNAL');
188
+ }
189
+ });
190
+
191
+ it('advertises sasl=PLAIN when the transport-level EXTERNAL override is set, even with mTLS + opt-in on a secure connection', () => {
192
+ // A transport that cannot surface a client-cert identity (the
193
+ // Spectrum TCP container origin) pins the mechanism off via
194
+ // sasl.externalUnsupportedMessage: the cap must never advertise a
195
+ // mechanism AUTHENTICATE would refuse with a transport-specific 904.
196
+ const state = makeState();
197
+ state.secure = true;
198
+ const provider: MtlsIdentityProvider = {
199
+ getIdentity: () => certIdentityFromSubject('CN=alice'),
200
+ };
201
+ const cfg: ServerConfig = {
202
+ ...serverConfig,
203
+ sasl: {
204
+ externalEnabled: true,
205
+ externalUnsupportedMessage: 'SASL EXTERNAL not supported on this transport',
206
+ },
207
+ };
208
+ const ctx = makeCtx(state, cfg, provider);
209
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
210
+ const send = out.effects[0];
211
+ expect(send).toBeDefined();
212
+ if (send?.tag === 'Send') {
213
+ const body = send.lines[0]?.text ?? '';
214
+ expect(body).toContain('sasl=PLAIN');
215
+ expect(body).not.toContain('sasl=PLAIN,EXTERNAL');
216
+ }
217
+ });
218
+
145
219
  it('advertises draft/multiline=4096 with the default byte budget', () => {
146
220
  const state = makeState();
147
221
  const ctx = makeCtx(state);
@@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  CHANSERV_NICK,
4
4
  DEFAULT_CHANNEL_LEVELS,
5
+ FOUNDER_RANK,
5
6
  SHORTHAND_LEVELS,
6
7
  chanservReducer,
7
8
  positiveMlockLetters,
9
+ rankOf,
8
10
  resolveMembershipTarget,
9
11
  } from '../../src/commands/chanserv';
10
12
  import { Effect } from '../../src/effects';
@@ -1670,6 +1672,29 @@ describe('SHORTHAND_LEVELS / DEFAULT_CHANNEL_LEVELS constants', () => {
1670
1672
  });
1671
1673
  });
1672
1674
 
1675
+ describe('rankOf / FOUNDER_RANK', () => {
1676
+ it('maps positive access levels to themselves', () => {
1677
+ expect(rankOf(SHORTHAND_LEVELS.VOP)).toBe(SHORTHAND_LEVELS.VOP);
1678
+ expect(rankOf(SHORTHAND_LEVELS.SOP)).toBe(SHORTHAND_LEVELS.SOP);
1679
+ expect(rankOf(7)).toBe(7);
1680
+ });
1681
+
1682
+ it('clamps zero and negative levels to the minimum rank', () => {
1683
+ expect(rankOf(0)).toBe(0);
1684
+ expect(rankOf(-3)).toBe(0);
1685
+ });
1686
+
1687
+ it('passes the founder rank through unchanged', () => {
1688
+ expect(rankOf(FOUNDER_RANK)).toBe(Number.POSITIVE_INFINITY);
1689
+ });
1690
+
1691
+ it('FOUNDER_RANK outranks every shorthand access level', () => {
1692
+ for (const level of Object.values(SHORTHAND_LEVELS)) {
1693
+ expect(FOUNDER_RANK).toBeGreaterThan(level);
1694
+ }
1695
+ });
1696
+ });
1697
+
1673
1698
  // ============================================================================
1674
1699
  // OP / DEOP / VOICE / DEVOICE
1675
1700
  // ============================================================================
@@ -1895,6 +1920,109 @@ describe('chanservReducer — DEOP', () => {
1895
1920
  ':ChanServ!ChanServ@services NOTICE mallory :Permission denied.',
1896
1921
  );
1897
1922
  });
1923
+
1924
+ it('rejects DEOP of the channel founder by an AUTOOP-level access entry', () => {
1925
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1926
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1927
+ services.registerNick('carol', 'pw', 'c@example.com');
1928
+ services.registerChannel('#home', 'alice');
1929
+ services.setChannelAccess('#home', 'carol', DEFAULT_CHANNEL_LEVELS.AUTOOP);
1930
+ const conn = makeConn('c1', 'carol');
1931
+ conn.account = 'carol';
1932
+ conn.userModes.registered = true;
1933
+ const ctx = makeCtx(conn, services);
1934
+
1935
+ const out = chanservReducer(conn, privmsg('DEOP #home alice'), ctx);
1936
+
1937
+ expect(firstBroadcast(out.effects)).toBeUndefined();
1938
+ expect(firstApplyChannelDelta(out.effects)).toBeUndefined();
1939
+ expect(sentTexts(out.effects)).toContain(
1940
+ ':ChanServ!ChanServ@services NOTICE carol :You do not have sufficient privileges on #home to affect alice.',
1941
+ );
1942
+ });
1943
+
1944
+ it('allows peer-level DEOP between AUTOOP-level access entries', () => {
1945
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1946
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1947
+ services.registerNick('carol', 'pw', 'c@example.com');
1948
+ services.registerNick('dave', 'pw', 'd@example.com');
1949
+ services.registerChannel('#home', 'alice');
1950
+ services.setChannelAccess('#home', 'carol', DEFAULT_CHANNEL_LEVELS.AUTOOP);
1951
+ services.setChannelAccess('#home', 'dave', DEFAULT_CHANNEL_LEVELS.AUTOOP);
1952
+ const conn = makeConn('c1', 'carol');
1953
+ conn.account = 'carol';
1954
+ conn.userModes.registered = true;
1955
+ const ctx = makeCtx(conn, services);
1956
+
1957
+ const out = chanservReducer(conn, privmsg('DEOP #home dave'), ctx);
1958
+
1959
+ expect(firstBroadcast(out.effects)).toBeDefined();
1960
+ expect(firstApplyChannelDelta(out.effects)).toBeDefined();
1961
+ expect(sentTexts(out.effects)).toContain(
1962
+ ':ChanServ!ChanServ@services NOTICE carol :Set mode -o on dave on #home.',
1963
+ );
1964
+ });
1965
+
1966
+ it('rejects DEOP of a higher-ranked access entry by a lower-ranked caller', () => {
1967
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1968
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1969
+ services.registerNick('carol', 'pw', 'c@example.com');
1970
+ services.registerNick('dave', 'pw', 'd@example.com');
1971
+ services.registerChannel('#home', 'alice');
1972
+ services.setChannelAccess('#home', 'carol', DEFAULT_CHANNEL_LEVELS.AUTOOP);
1973
+ services.setChannelAccess('#home', 'dave', SHORTHAND_LEVELS.SOP);
1974
+ const conn = makeConn('c1', 'carol');
1975
+ conn.account = 'carol';
1976
+ conn.userModes.registered = true;
1977
+ const ctx = makeCtx(conn, services);
1978
+
1979
+ const out = chanservReducer(conn, privmsg('DEOP #home dave'), ctx);
1980
+
1981
+ expect(firstBroadcast(out.effects)).toBeUndefined();
1982
+ expect(firstApplyChannelDelta(out.effects)).toBeUndefined();
1983
+ expect(sentTexts(out.effects)).toContain(
1984
+ ':ChanServ!ChanServ@services NOTICE carol :You do not have sufficient privileges on #home to affect dave.',
1985
+ );
1986
+ });
1987
+
1988
+ it('allows an SOP-level entry to DEOP an AOP-level target', () => {
1989
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1990
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1991
+ services.registerNick('carol', 'pw', 'c@example.com');
1992
+ services.registerNick('dave', 'pw', 'd@example.com');
1993
+ services.registerChannel('#home', 'alice');
1994
+ services.setChannelAccess('#home', 'carol', SHORTHAND_LEVELS.SOP);
1995
+ services.setChannelAccess('#home', 'dave', DEFAULT_CHANNEL_LEVELS.AUTOOP);
1996
+ const conn = makeConn('c1', 'carol');
1997
+ conn.account = 'carol';
1998
+ conn.userModes.registered = true;
1999
+ const ctx = makeCtx(conn, services);
2000
+
2001
+ const out = chanservReducer(conn, privmsg('DEOP #home dave'), ctx);
2002
+
2003
+ expect(firstBroadcast(out.effects)).toBeDefined();
2004
+ expect(sentTexts(out.effects)).toContain(
2005
+ ':ChanServ!ChanServ@services NOTICE carol :Set mode -o on dave on #home.',
2006
+ );
2007
+ });
2008
+
2009
+ it('allows the founder to DEOP themselves', () => {
2010
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
2011
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2012
+ services.registerChannel('#home', 'alice');
2013
+ const conn = makeConn();
2014
+ conn.account = 'alice';
2015
+ conn.userModes.registered = true;
2016
+ const ctx = makeCtx(conn, services);
2017
+
2018
+ const out = chanservReducer(conn, privmsg('DEOP #home alice'), ctx);
2019
+
2020
+ expect(firstBroadcast(out.effects)).toBeDefined();
2021
+ expect(firstApplyChannelDelta(out.effects)).toBeDefined();
2022
+ expect(sentTexts(out.effects)).toContain(
2023
+ ':ChanServ!ChanServ@services NOTICE alice :Set mode -o on alice on #home.',
2024
+ );
2025
+ });
1898
2026
  });
1899
2027
 
1900
2028
  describe('chanservReducer — VOICE', () => {
@@ -2076,6 +2204,44 @@ describe('chanservReducer — KICK', () => {
2076
2204
  ':ChanServ!ChanServ@services NOTICE alice :Syntax: KICK <#channel> <nick> [:<reason>].',
2077
2205
  );
2078
2206
  });
2207
+
2208
+ it('rejects KICK of the channel founder by an AUTOOP-level caller', () => {
2209
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
2210
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2211
+ services.registerNick('carol', 'pw', 'c@example.com');
2212
+ services.registerChannel('#home', 'alice');
2213
+ services.setChannelAccess('#home', 'carol', DEFAULT_CHANNEL_LEVELS.AUTOOP);
2214
+ const conn = makeConn('c1', 'carol');
2215
+ conn.account = 'carol';
2216
+ conn.userModes.registered = true;
2217
+ const ctx = makeCtx(conn, services);
2218
+
2219
+ const out = chanservReducer(conn, privmsg('KICK #home alice'), ctx);
2220
+
2221
+ expect(firstBroadcast(out.effects)).toBeUndefined();
2222
+ expect(firstApplyChannelDelta(out.effects)).toBeUndefined();
2223
+ expect(sentTexts(out.effects)).toContain(
2224
+ ':ChanServ!ChanServ@services NOTICE carol :Permission denied.',
2225
+ );
2226
+ });
2227
+
2228
+ it('allows the founder to KICK themselves (self-target through the rank guard)', () => {
2229
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
2230
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2231
+ services.registerChannel('#home', 'alice');
2232
+ const conn = makeConn();
2233
+ conn.account = 'alice';
2234
+ conn.userModes.registered = true;
2235
+ const ctx = makeCtx(conn, services);
2236
+
2237
+ const out = chanservReducer(conn, privmsg('KICK #home alice'), ctx);
2238
+
2239
+ expect(firstBroadcast(out.effects)).toBeDefined();
2240
+ expect(firstApplyChannelDelta(out.effects)).toBeDefined();
2241
+ expect(sentTexts(out.effects)).toContain(
2242
+ ':ChanServ!ChanServ@services NOTICE alice :Kicked alice from #home.',
2243
+ );
2244
+ });
2079
2245
  });
2080
2246
 
2081
2247
  // ============================================================================
@@ -1013,6 +1013,146 @@ describe('chathistoryReducer — TAGMSG replay', () => {
1013
1013
  });
1014
1014
  });
1015
1015
 
1016
+ // ============================================================================
1017
+ // Limit ceiling (L9)
1018
+ //
1019
+ // The client-supplied limit is capped at a deployment-configurable ceiling
1020
+ // (ServerConfig.chathistory.maxLimit, default 100) so an oversized request
1021
+ // like `CHATHISTORY LATEST #chan 999999999` cannot ask a future higher-
1022
+ // capacity store to materialize a huge result set. The cap is silent: the
1023
+ // client simply receives at most the ceiling worth of replay lines.
1024
+ // ============================================================================
1025
+
1026
+ describe('chathistoryReducer — limit ceiling', () => {
1027
+ /** Number of replay lines inside the emitted BATCH frame (0 when elided). */
1028
+ function replayCount(effects: EffectType[]): number {
1029
+ if (effects.length === 0) return 0;
1030
+ const send = effects[0] as { lines: RawLine[] };
1031
+ return send.lines.length - 2; // minus BATCH start + BATCH end
1032
+ }
1033
+
1034
+ /** Ctx over a store that retains more than the default ceiling. */
1035
+ function makeCappedCtx(conn: ConnectionState, store: MessageStore, config: ServerConfig): Ctx {
1036
+ return buildCtx({
1037
+ serverConfig: config,
1038
+ clock: new FakeClock(10_000),
1039
+ ids: new SequentialIdFactory(),
1040
+ motd: EmptyMotdProvider,
1041
+ messages: store,
1042
+ connection: conn,
1043
+ });
1044
+ }
1045
+
1046
+ it('caps an oversized limit at the default ceiling of 100', () => {
1047
+ const store = new InMemoryMessageStore(1000);
1048
+ seed(store, '#foo', 150);
1049
+ const conn = makeConn();
1050
+ const chan = makeChan();
1051
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
1052
+ const ctx = makeCappedCtx(conn, store, serverConfig);
1053
+
1054
+ const out = chathistoryReducer(
1055
+ chan,
1056
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '999999999'], tags: {} },
1057
+ ctx,
1058
+ );
1059
+
1060
+ expect(replayCount(out.effects)).toBe(100);
1061
+ });
1062
+
1063
+ it('honors a limit at exactly the ceiling (100 passes through uncapped)', () => {
1064
+ const store = new InMemoryMessageStore(1000);
1065
+ seed(store, '#foo', 150);
1066
+ const conn = makeConn();
1067
+ const chan = makeChan();
1068
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
1069
+ const ctx = makeCappedCtx(conn, store, serverConfig);
1070
+
1071
+ const out = chathistoryReducer(
1072
+ chan,
1073
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '100'], tags: {} },
1074
+ ctx,
1075
+ );
1076
+
1077
+ expect(replayCount(out.effects)).toBe(100);
1078
+ });
1079
+
1080
+ it('passes a below-ceiling limit through unchanged', () => {
1081
+ const store = new InMemoryMessageStore(1000);
1082
+ seed(store, '#foo', 150);
1083
+ const conn = makeConn();
1084
+ const chan = makeChan();
1085
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
1086
+ const ctx = makeCappedCtx(conn, store, serverConfig);
1087
+
1088
+ const out = chathistoryReducer(
1089
+ chan,
1090
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '5'], tags: {} },
1091
+ ctx,
1092
+ );
1093
+
1094
+ expect(replayCount(out.effects)).toBe(5);
1095
+ });
1096
+
1097
+ it('caps at a configured lower ceiling via serverConfig.chathistory.maxLimit', () => {
1098
+ const store = new InMemoryMessageStore(1000);
1099
+ seed(store, '#foo', 150);
1100
+ const conn = makeConn();
1101
+ const chan = makeChan();
1102
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
1103
+ const ctx = makeCappedCtx(conn, store, {
1104
+ ...serverConfig,
1105
+ chathistory: { maxLimit: 3 },
1106
+ });
1107
+
1108
+ const out = chathistoryReducer(
1109
+ chan,
1110
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '999999999'], tags: {} },
1111
+ ctx,
1112
+ );
1113
+
1114
+ expect(replayCount(out.effects)).toBe(3);
1115
+ });
1116
+
1117
+ it('caps the default (omitted) limit at a configured lower ceiling', () => {
1118
+ const store = new InMemoryMessageStore(1000);
1119
+ seed(store, '#foo', 150);
1120
+ const conn = makeConn();
1121
+ const chan = makeChan();
1122
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
1123
+ const ctx = makeCappedCtx(conn, store, {
1124
+ ...serverConfig,
1125
+ chathistory: { maxLimit: 3 },
1126
+ });
1127
+
1128
+ const out = chathistoryReducer(
1129
+ chan,
1130
+ // No limit param — the default 50 must still respect the ceiling of 3.
1131
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*'], tags: {} },
1132
+ ctx,
1133
+ );
1134
+
1135
+ expect(replayCount(out.effects)).toBe(3);
1136
+ });
1137
+
1138
+ it('returns 461 for a negative limit', () => {
1139
+ const store = new InMemoryMessageStore();
1140
+ seed(store, '#foo', 3);
1141
+ const conn = makeConn();
1142
+ const chan = makeChan();
1143
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
1144
+ const ctx = makeCtx(conn, store);
1145
+
1146
+ const out = chathistoryReducer(
1147
+ chan,
1148
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '-5'], tags: {} },
1149
+ ctx,
1150
+ );
1151
+
1152
+ expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');
1153
+ });
1154
+ });
1155
+
1016
1156
  // ============================================================================
1017
1157
  // No MessageStore bound (deployment disabled chathistory persistence)
1018
1158
  // ============================================================================