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,175 @@
1
+ # SASL EXTERNAL (CertFP) — certificate-to-account binding
2
+
3
+ Operator and contributor reference for how `AUTHENTICATE EXTERNAL`
4
+ (ServerlessIRCd's mTLS-backed SASL mechanism) turns a verified client
5
+ certificate into a logged-in NickServ account.
6
+
7
+ Cross-reference: the `MtlsIdentityProvider` port and
8
+ `ServicesStore.verifyCertFP` in `packages/irc-core/src/ports.ts`, the
9
+ canonicaliser in `packages/irc-core/src/certfp.ts`, the SASL EXTERNAL
10
+ reducer in `packages/irc-core/src/commands/sasl.ts`, and
11
+ `extractMtlsSubject` in `packages/cf-adapter/src/connection-do.ts`.
12
+
13
+ ---
14
+
15
+ ## 1. The chosen identifier
16
+
17
+ Account binding uses, in preference order:
18
+
19
+ 1. **DER SHA-256 fingerprint (primary).** When the platform surfaces a
20
+ SHA-256 fingerprint of the certificate's DER encoding, it is the
21
+ account binding. Stored as `fp:<hex>` (lowercase, colon-free) in the
22
+ nick row's `certSubjects` list. A fingerprint binds **this exact
23
+ certificate** — a CA re-issuing a different cert with the same subject
24
+ does NOT map to the account, and two semantically-identical certs with
25
+ different DER do not collide.
26
+ 2. **Canonical DN (fallback).** On platforms that surface only the
27
+ certificate subject DN, the DN is canonicalised (see §3) and compared
28
+ as the binding. Subject-bound accounts accept any certificate whose
29
+ canonical DN matches — the Atheme-style `CertFP` behaviour.
30
+
31
+ ### Platform availability (verified)
32
+
33
+ | Platform | Fields surfaced | Identifier used |
34
+ | --- | --- | --- |
35
+ | Cloudflare Workers (API Shield mTLS) | `request.cf.tlsClientAuthCertSubject` (legacy documented field) and `request.cf.tlsClientAuth.certSubjectDN`, **plus** `request.cf.tlsClientAuth.certFingerprintSHA256` (lowercase-hex SHA-256 of the DER; `""` on non-mTLS connections) | **Fingerprint** when non-empty, canonical DN otherwise |
36
+ | AWS API Gateway (custom-domain mTLS) | `requestContext.identity.clientCertSubjectDN` only — no fingerprint, no DER | **Canonical DN** |
37
+
38
+ Neither platform hands the raw DER to the WebSocket handler directly
39
+ (Cloudflare does expose the leaf cert RFC 9440-encoded under
40
+ `cf.tlsClientAuth.certRFC9440`, but the platform-computed
41
+ `certFingerprintSHA256` is strictly simpler and equivalent for binding,
42
+ and is preferred). The fingerprint therefore never needs to be computed
43
+ in-process; `certfp.ts` only normalises the platform-supplied hex
44
+ (lowercase, colon-stripping) so `AA:11` and `aa11` are the same binding.
45
+
46
+ ## 2. Flow
47
+
48
+ ```
49
+ TLS handshake (edge terminates, validates client cert against CA store)
50
+ └─ adapter admission: extractMtlsSubject(request) → CertIdentity
51
+ { subject: raw string,
52
+ canonicalDn: canonicalizeCertSubject(subject),
53
+ fingerprint?: platform SHA-256 hex }
54
+ └─ stored per-connection, surfaced via MtlsIdentityProvider.getIdentity(connId)
55
+ client: CAP REQ sasl → AUTHENTICATE EXTERNAL → AUTHENTICATE +
56
+ └─ sasl reducer: identity = provider.getIdentity(connId)
57
+ └─ ServicesStore.verifyCertFP(identity):
58
+ arm 1: fp:<hex> byte-exact match against certSubjects (primary)
59
+ arm 2: canonical-DN match against DN entries (fallback)
60
+ └─ 900 RPL_LOGGEDIN + 903 RPL_SASLSUCCESS, or 904 ERR_SASLFAIL
61
+ ```
62
+
63
+ ## 2a. Operator opt-in — the three-way gate
64
+
65
+ EXTERNAL is only advertised (in the `sasl` cap value and the `908
66
+ ERR_SASLMECHS` list) and only accepted when **all three** of these hold:
67
+
68
+ 1. **An mTLS identity source is bound** for the deployment (CF API Shield
69
+ client certs / AWS APIGW custom-domain mTLS — the
70
+ `MtlsIdentityProvider` port returns an identity for the connection).
71
+ 2. **The operator opted in**: set `EXTERNAL_ENABLED=true` (or `1`) in the
72
+ CF Worker vars / Lambda environment. The adapters map it to
73
+ `sasl.externalEnabled` in the parsed server config; unset or any other
74
+ value keeps the mechanism off (a typo never silently enables it).
75
+ 3. **The connection is secure (TLS)** — the client-cert identity only
76
+ carries meaning over a transport the server trusts (the NLB+TLS
77
+ secure-connection flag on the AWS path, the always-TLS edge on CF).
78
+
79
+ If any one of the three is missing, `AUTHENTICATE EXTERNAL` is refused
80
+ with `908 ERR_SASLMECHS` listing `PLAIN` only, and the refusal does not
81
+ count toward the per-connection SASL failure lockout (it is a
82
+ protocol-level mechanism rejection, not a failed credential attempt).
83
+
84
+ ### Transport availability — the cf-tcp-container exclusion
85
+
86
+ The three-way gate is the *generic* refusal path. One transport
87
+ additionally pins the mechanism off explicitly: the **cf-tcp-container**
88
+ origin (Cloudflare Spectrum → Container, the Cloudflare
89
+ `irc+tls :6697` path). Spectrum forwards with PROXY protocol v1, which
90
+ carries no client-cert subject, and although `tls_mode = "full"`
91
+ terminates TLS at the origin itself, no client-certificate trust pool
92
+ is wired through to the IRC core — there is no `MtlsIdentityProvider`
93
+ to feed, so EXTERNAL can never succeed there regardless of the Spectrum
94
+ TLS mode. Decision (documented, option *explicit non-support*): rather
95
+ than letting `AUTHENTICATE EXTERNAL` fail generically at the gate, the
96
+ container sets the `sasl.externalUnsupportedMessage` transport
97
+ override, so:
98
+
99
+ - the `sasl` cap value and the `908 ERR_SASLMECHS` list advertise
100
+ `PLAIN` only, and
101
+ - `AUTHENTICATE EXTERNAL` is rejected up front with `904 ERR_SASLFAIL`
102
+ carrying the stable message
103
+ `SASL EXTERNAL not supported on this transport`.
104
+
105
+ The refusal does not count toward the per-connection SASL failure
106
+ lockout (a transport capability statement, not a failed credential
107
+ attempt). The override wins even if `EXTERNAL_ENABLED` is set — a
108
+ transport without an mTLS identity source must not half-accept the
109
+ exchange. Extracting the client-cert subject from PROXY-protocol-v2
110
+ TLVs (which Spectrum can emit) is the documented follow-up if demand
111
+ materialises; until then SASL EXTERNAL is available on the **wss**
112
+ (Cloudflare Workers + API Shield mTLS) and **NLB+TLS** (AWS API Gateway
113
+ custom-domain mTLS) paths only.
114
+
115
+ ## 3. DN canonicalisation (RFC 4514-grounded)
116
+
117
+ Subject DNs are compared after canonicalisation
118
+ (`canonicalizeCertSubject` in `packages/irc-core/src/certfp.ts`):
119
+
120
+ 1. Attribute **types** are lowercased (`CN=` → `cn=`); attribute
121
+ **values** keep their case and compare **case-sensitively**.
122
+ 2. Whitespace around types/values is trimmed; runs of unescaped
123
+ whitespace inside a value collapse to a single space. Escaped
124
+ whitespace (e.g. a trailing `\ `) is preserved verbatim.
125
+ 3. RDNs (`a,b`) and the AVAs of a multi-valued RDN (`a+b`) are sorted, so
126
+ two semantically-equal DNs written in different orders or spacing
127
+ collapse to one canonical spelling:
128
+ `CN=alice , O = Example Inc` ≡ `O=Example Inc,CN=alice`.
129
+ 4. RFC 4514 escapes (`\,`, `\+`, `\=` …) pass through intact — the
130
+ canonicaliser never splits on an escaped separator.
131
+
132
+ Two certs whose DNs differ only in spacing/ordering therefore map to the
133
+ same account; DNs that differ in **value case** do not.
134
+
135
+ ## 4. First-use migration of legacy bindings
136
+
137
+ Accounts created before this scheme stored the platform subject string
138
+ verbatim. `verifyCertFP` handles them transparently:
139
+
140
+ - On a canonical-DN arm match, a stored legacy spelling is **rewritten**
141
+ to its canonical form in the nick row.
142
+ - When the platform supplied a fingerprint, the `fp:<hex>` binding is
143
+ **pinned** alongside the DN entry, so subsequent verifies hit the
144
+ fingerprint arm.
145
+ - The `PersistentServicesStore` subclass re-persists the nick row on
146
+ both rewrites (write-behind `upsertNick`), so the migration survives a
147
+ backend reload. No operator action is required; there is no separate
148
+ migration script.
149
+
150
+ ## 5. Attaching bindings
151
+
152
+ - `ServicesStore.addCertFingerprint(nick, fp)` — attach a fingerprint
153
+ binding (normalised to `fp:<hex>`; idempotent).
154
+ - `ServicesStore.addCertFP(nick, dn)` — attach a subject-DN binding
155
+ (canonicalised at verify time; idempotent).
156
+ - **These are the binding seam.** Both are programmatic store APIs
157
+ today — persistence is the `certSubjects` JSON column on the D1
158
+ `nickserv_accounts` row (added by the store's idempotent
159
+ `ALTER TABLE ... ADD COLUMN cert_subjects` runtime migration) and the
160
+ `certSubjects` list attribute on the DynamoDB `Services` item
161
+ (schemaless; no table migration needed). The NickServ `CERT` command
162
+ (Atheme parity) that surfaces them to users is the documented
163
+ follow-up: it will call exactly these two mutators, nothing else.
164
+
165
+ ## 6. Failure modes
166
+
167
+ | Condition | Result |
168
+ | --- | --- |
169
+ | `EXTERNAL_ENABLED` unset/false (default) | EXTERNAL refused at mechanism phase (`908`, `PLAIN` only) — flag off means off |
170
+ | Insecure (non-TLS) connection | EXTERNAL refused at mechanism phase (`908`) even with mTLS + flag on |
171
+ | No mTLS configured (no `MtlsIdentityProvider` bound) | EXTERNAL refused at mechanism phase (`908`) |
172
+ | cf-tcp-container transport (`sasl.externalUnsupportedMessage` override) | up-front `904 ERR_SASLFAIL` — "SASL EXTERNAL not supported on this transport" (not counted toward the lockout) |
173
+ | Cert presented but no account carries a matching fingerprint or canonical DN | `904 ERR_SASLFAIL` ("untrusted certificate") |
174
+ | Fingerprint bound to account A, DN bound to account B | fingerprint arm wins — the verify returns account A |
175
+ | Platform surfaces no fingerprint (API Gateway) | DN arm only; binding is DN-scoped |
package/docs/Services.md CHANGED
@@ -46,8 +46,9 @@ ServerlessIRCd chose **integrated services**. The rationale (full detail in
46
46
  - The pure-reducer + location-of-authority architecture already has the
47
47
  right seam: a `ServicesStore` port absorbs the side effects, and reducers
48
48
  stay pure and unit-testable.
49
- - One storage layer is shared conceptually with the SASL `AccountStore`
50
- authentication and nick ownership are meant to compose, not duplicate.
49
+ - One storage layer (`ServicesStore`) is the **single credential home**
50
+ for SASL PLAIN, SASL EXTERNAL, PASS-auth, and NickServ IDENTIFY
51
+ authentication and nick ownership compose, not duplicate.
51
52
  - The serverless substrates (Cloudflare Durable Objects, AWS DynamoDB)
52
53
  handle persistence natively; a separate long-running services process
53
54
  would defeat the deployment model.
@@ -90,6 +91,12 @@ If no store is bound, every services command replies:
90
91
  milliseconds, NickServ waits before force-disconnecting a `ghost`-enforced
91
92
  client. `0` makes `ghost` behave like `kill` (immediate). Omit for the
92
93
  **30 000 ms** default (`DEFAULT_NICK_ENFORCE_GRACE_MS`).
94
+ - **`nickServ.minPasswordLength`** (`integer > 0`, optional) — minimum
95
+ length enforced by NickServ `SET PASSWORD` for the new password. Omit for
96
+ the **8** default (`DEFAULT_MIN_PASSWORD_LENGTH`); a shorter new password
97
+ is rejected before hashing with
98
+ `Password is too short (minimum <n> characters).`. The maximum (256) is
99
+ fixed, not configurable, to bound the scrypt input budget.
93
100
 
94
101
  ---
95
102
 
@@ -176,6 +183,7 @@ policy, and nick-info queries. A registered nick is an account.
176
183
  | `DROP` | `PRIVMSG NickServ :DROP [nick]` | Requires prior identify. Drops the registration, clears `state.account` and `+r`, fans `ACCOUNT *`. Reply: `Nickname <nick> has been dropped.` |
177
184
  | `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.` |
178
185
  | `SET ENFORCE` | `PRIVMSG NickServ :SET ENFORCE none|ghost|kill` | Requires identify as the owning account. See enforcement below. |
186
+ | `SET PASSWORD` | `PRIVMSG NickServ :SET PASSWORD <old-password> <new-password>` | Requires identify as the owning account **and** re-supplies the current password (defence against a hijacked `+r` session). Re-hashes the new password (scrypt) and overwrites the credential. New-password length floor: `nickServ.minPasswordLength` (default **8**); fixed ceiling **256**. Reply: `Password changed.` Wrong old password → `Invalid password.` (same text as a failed `IDENTIFY`; no leak). The current session stays `+r`; **other sessions of the same account are NOT kicked.** |
179
187
 
180
188
  Help / unknown command: `Available commands: REGISTER, IDENTIFY, DROP, INFO, SET`.
181
189
 
@@ -431,27 +439,24 @@ Both accept `--accounts alice:s3cret bob:pw …` or `--file accounts.txt`
431
439
  ```
432
440
  # Cloudflare
433
441
  node --import tsx tools/seed-cf-accounts.ts \
434
- --database serverless-ircd-accounts-staging --env staging --remote \
442
+ --database serverless-ircd-accounts --remote \
435
443
  --accounts alice:s3cret
436
444
 
437
- # AWS
445
+ # AWS (the --table is the Services table, not the legacy Accounts table)
438
446
  node --import tsx tools/seed-aws-accounts.ts \
439
- --table StagingAccounts --endpoint http://localhost:8000 \
447
+ --table StagingServices --endpoint http://localhost:8000 \
440
448
  --accounts alice:s3cret
441
449
  ```
442
450
 
443
- > **NickServ accounts vs SASL accounts.** NickServ registrations live in
444
- > the **`ServicesStore`** (`nickserv_accounts`), which is unified with the
445
- > SASL `AccountStore` the seed scripts above populate: a NickServ
446
- > registration is a SASL account and vice versa, both scrypt-hashed (see
447
- > the [walkthrough](#13-end-to-end-walkthrough)). The recommended way to
448
- > create a NickServ account is the live
449
- > `PRIVMSG NickServ :REGISTER …` flow over an established connection. A
450
- > dedicated `tools/seed-nickserv-accounts.ts` mirroring the SASL seeders
451
- > is tracked as a follow-up; because credentials are scrypt-hashed at rest
452
- > (see [§11](#11-security-characteristics--known-gaps)), bulk-seeded rows
453
- > must supply a scrypt `algorithm`/`salt`/`hash` triple rather than a
454
- > plaintext password.
451
+ > **NickServ accounts vs SASL accounts — unified.** Both SASL PLAIN and
452
+ > NickServ IDENTIFY consult the **`ServicesStore`** (`nickserv_accounts`).
453
+ > There is no separate SASL credential table: a NickServ registration is a
454
+ > SASL account and vice versa, both scrypt-hashed (see the
455
+ > [walkthrough](#13-end-to-end-walkthrough)). The recommended way to create
456
+ > a NickServ account is the live `PRIVMSG NickServ :REGISTER …` flow over
457
+ > an established connection. The seed scripts above populate
458
+ > `nickserv_accounts` directly via the same scrypt-hashed `registerNick`
459
+ > path; the legacy standalone `accounts` / `Accounts` table was dropped.
455
460
 
456
461
  Operators can manage AKILL/JUPE entries via `PRIVMSG OperServ` (oper-only,
457
462
  see [§7](#7-operserv--network-bans-jupes-raw-oper-only)), or pre-populate
@@ -464,13 +469,13 @@ them by writing directly into the `ServicesStore` backing tables.
464
469
  Be aware of the following when operating or contributing to services:
465
470
 
466
471
  - **NickServ passwords are stored scrypt-hashed** in the `ServicesStore`
467
- (the same scrypt path the SASL `AccountStore` uses:
468
- `InMemoryServicesStore.registerNick` hashes via `hashAccountCredential`
472
+ (`InMemoryServicesStore.registerNick` hashes via `hashAccountCredential`
469
473
  before persisting; `verifyNick` re-derives via `verifyHashedPassword`;
470
474
  the D1 `nickserv_accounts` table stores `algorithm`/`salt`/`hash`
471
- columns, never the plaintext password). NickServ and SASL accounts are
472
- unified, so a NickServ registration is a SASL account and vice versa.
473
- Treat the services backing store (D1 database / DynamoDB table) as
475
+ columns, never the plaintext password). The `ServicesStore` is the
476
+ **single credential home**: SASL PLAIN, SASL EXTERNAL, PASS-auth
477
+ (`PASS <nick>:<password>`), and NickServ `IDENTIFY` all verify through
478
+ the same `verifyNick` / `verifyCertFP` surface. Treat the services backing store (D1 database / DynamoDB table) as
474
479
  sensitive regardless: restrict access and enable at-rest encryption
475
480
  (DynamoDB encryption-at-rest is on by default).
476
481
  - **HostServ `REQUEST` approval mode is configurable** — the default
@@ -576,3 +581,45 @@ When `bob` later identifies and joins, ChanServ auto-ops them:
576
581
 
577
582
  That completes the core nick + channel registration flow. From here, the
578
583
  per-service references in §4–§8 cover every shipped command.
584
+
585
+ ---
586
+
587
+ ## 14. Unified credential verification — four entry points
588
+
589
+ The `ServicesStore` is the **single credential home**. Every
590
+ account-setting path reduces to "this connection is now account X" via
591
+ one of four entry points, all of which consult the same scrypt-hashed
592
+ credential on the `nickserv_accounts` row:
593
+
594
+ | Entry point | Mechanism | Credential verify call | Reducer |
595
+ |-------------|-----------|------------------------|---------|
596
+ | **SASL PLAIN** | `AUTHENTICATE PLAIN` (IRCv3 `sasl`) | `services.verifyNick(username, password)` | `commands/sasl.ts` |
597
+ | **SASL EXTERNAL** (CertFP) | `AUTHENTICATE EXTERNAL` (IRCv3 `sasl` + mTLS) | `services.verifyCertFP(certSubject)` (subject resolved by the unchanged `MtlsIdentityProvider`) | `commands/sasl.ts` |
598
+ | **NickServ `IDENTIFY`** | `PRIVMSG NickServ :IDENTIFY <password>` | `services.verifyNick(state.nick, password)` | `commands/nickserv.ts` |
599
+ | **PASS-auth** | `PASS <nick>:<password>` at or after registration | `services.verifyNick(nick, password)` | `commands/account-auth.ts` (`attemptPassAccountAuth`) |
600
+
601
+ All four share the same `applyAccountSuccess` downstream chain: stamp
602
+ `state.account`, set read-only `+r`, seed read-markers, replay persisted
603
+ away, deliver queued memos, re-apply assigned vhost, and emit `900
604
+ RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS` + the `account-notify` `ACCOUNT`
605
+ broadcast.
606
+
607
+ A NickServ-registered nick authenticates through any of the four entry
608
+ points without a second registration — the credential set by
609
+ `PRIVMSG NickServ :REGISTER` IS the SASL/PASS credential.
610
+
611
+ ### SASL EXTERNAL (CertFP)
612
+
613
+ SASL EXTERNAL maps a verified client-certificate subject to an account.
614
+ The edge platform (CF API Shield mTLS, AWS API Gateway custom-domain
615
+ mTLS) terminates TLS and validates the client cert; the verified subject
616
+ is surfaced to the Worker / Lambda via the unchanged
617
+ `MtlsIdentityProvider` port. The subject→account lookup lives in
618
+ `ServicesStore.verifyCertFP`, which scans `certSubjects: string[]` on
619
+ each registered nick (byte-exact, case-sensitive comparison — cert
620
+ subjects are NOT case-folded). `addCertFP(account, subject)` /
621
+ `removeCertFP(account, subject)` mutators are shipped for an upcoming
622
+ NickServ `CERT ADD`/`LIST`/`DEL` command (Atheme parity); the
623
+ `cert_subjects` column on `nickserv_accounts` / the `certSubjects`
624
+ attribute on the DynamoDB `Services` item carries the array (JSON-encoded
625
+ `'[]'` by default).
@@ -11,9 +11,19 @@ Cross-reference: `PLAN.md` §6.1 (CF mapping), `docs/PlanWebClient.md`
11
11
  (design), `docs/Cloudflare-Deployment-Guide.md` (Worker deploy),
12
12
  `apps/cf-worker/wrangler.toml`, `apps/web/`.
13
13
 
14
+ > **AWS counterpart.** This doc covers the **Cloudflare** web-client
15
+ > path, where the Worker serves both the SPA and the WebSocket from
16
+ > one origin. The AWS deployment **cannot** unify them — the SPA
17
+ > lives on CloudFront (S3 + OAC) and the WebSocket endpoint on API
18
+ > Gateway, so the baked config carries the API Gateway wss URL as a
19
+ > literal and the deploy is a separate two-phase pipeline (stack →
20
+ > bake → s3 sync → invalidate). For the AWS web client — construct,
21
+ > build, deploy pipeline, the `WEB_ORIGINS` CSWSH defence, and custom
22
+ > domain — see **`docs/AWS-Deployment.md` §16 (Web client)**.
23
+
14
24
  **Acceptance criterion (TICKET-139):** a new contributor can
15
25
  `git submodule update --init`, `pnpm install`,
16
- `pnpm --filter web build:staging`, and `wrangler deploy --env staging`
26
+ `pnpm --filter web build`, and `wrangler deploy`
17
27
  following only this doc.
18
28
 
19
29
  ---
@@ -82,7 +92,7 @@ pnpm --version # 11.x
82
92
 
83
93
  ---
84
94
 
85
- ## 3. Quickstart (clean checkout → deployed staging SPA)
95
+ ## 3. Quickstart (clean checkout → deployed SPA)
86
96
 
87
97
  ```bash
88
98
  # 1. Pull submodules (docs wiki + Kiwi IRC upstream sources).
@@ -91,9 +101,9 @@ git submodule update --init
91
101
  # 2. Install workspace deps.
92
102
  pnpm install
93
103
 
94
- # 3. Build the SPA + landing page (default config = staging target).
95
- pnpm --filter web build:staging
96
- # Equivalent: pnpm --filter @serverless-ircd/web build:staging
104
+ # 3. Build the SPA + landing page (default config = dev target).
105
+ pnpm --filter web build
106
+ # Equivalent: pnpm --filter @serverless-ircd/web build
97
107
  # → apps/web/dist/index.html (landing page)
98
108
  # → apps/web/dist/webclient/index.html (Kiwi SPA)
99
109
  # → apps/web/dist/webclient/static/config.json (baked, env-specific)
@@ -107,9 +117,9 @@ pnpm --filter @serverless-ircd/cf-worker dev
107
117
  # → http://localhost:8787/webclient/ Kiwi SPA
108
118
  # → ws://localhost:8787/ IRC-over-WebSocket (same endpoint)
109
119
 
110
- # 6. Deploy staging (Worker + assets in one command).
111
- pnpm deploy:cf:staging
112
- # → wrangler deploy --env staging
120
+ # 6. Deploy (Worker + assets in one command).
121
+ pnpm deploy:cf
122
+ # → wrangler deploy
113
123
  ```
114
124
 
115
125
  Open `http://localhost:8787/webclient/` in a browser — Kiwi boots, reads
@@ -131,14 +141,14 @@ The build is an orchestrated layering over the upstream Kiwi build —
131
141
 
132
142
  | `pnpm ...` command | Reads | Bakes into `dist/webclient/static/config.json` |
133
143
  |---|---|---|
134
- | `--filter web build` | `static/config.json` | default (staging-shaped) config |
135
- | `--filter web build:staging` | `static/config.staging.json` | staging config |
144
+ | `--filter web build` | `static/config.json` | dev default config |
136
145
  | `--filter web build:prod` | `static/config.prod.json` | prod config |
137
146
 
138
- `--env` is parsed by `src/build-env.ts`; an unknown value aborts the
139
- build with a readable error. The selected file is validated against the
140
- Zod schema (`src/config-schema.ts`) **before** it is baked, so a
141
- malformed config fails the build rather than shipping a broken SPA.
147
+ `--env` is parsed by `src/build-env.ts`; an unknown value (including the
148
+ retired `staging`) aborts the build with a readable error. The selected
149
+ file is validated against the Zod schema (`src/config-schema.ts`)
150
+ **before** it is baked, so a malformed config fails the build rather
151
+ than shipping a broken SPA.
142
152
 
143
153
  ### 4.2 What the build does, in order
144
154
 
@@ -172,9 +182,9 @@ The schema models only the fields ServerlessIRCd cares about; the rest
172
182
  of Kiwi's surface is passed through untouched (`.passthrough()`).
173
183
 
174
184
  ```jsonc
175
- // static/config.staging.json
185
+ // static/config.prod.json
176
186
  {
177
- "windowTitle": "ServerlessIRCd (staging)",
187
+ "windowTitle": "ServerlessIRCd",
178
188
  "startupOptions": {
179
189
  "server": "{{hostname}}", // resolved to window.location.hostname at boot
180
190
  "direct": true, // MUST be true — direct WS, no kiwiServer gateway
@@ -191,7 +201,7 @@ Field notes (see the schema docstrings for the full rationale):
191
201
  - **`startupOptions.server`** — `{{hostname}}` (or `{{host}}` to keep
192
202
  a non-default port) is a Kiwi template resolved by the browser to
193
203
  `window.location.hostname` at load time. This is what lets one build
194
- artifact target staging, prod, preview, and PR deploys without a
204
+ artifact target prod, preview, and PR deploys without a
195
205
  per-env hostname. A literal hostname is also accepted.
196
206
  - **`startupOptions.direct`** — MUST be `true`. ServerlessIRCd is a
197
207
  direct IRC-over-WebSocket server; the Kiwi default (`false`) routes
@@ -209,7 +219,7 @@ Field notes (see the schema docstrings for the full rationale):
209
219
  A malformed config aborts the build with an aggregated error, e.g.:
210
220
 
211
221
  ```
212
- config.staging.json: Invalid Kiwi web config:
222
+ config.prod.json: Invalid Kiwi web config:
213
223
  - startupOptions.port: port must be a valid TCP port (1-65535)
214
224
  - startupOptions.channel: must begin with '#'
215
225
  ```
@@ -275,7 +285,7 @@ WEB_ORIGINS = "https://irc.example.com,https://web.example.com"
275
285
 
276
286
  ```bash
277
287
  # or per-deploy via the secret store / dashboard env vars:
278
- wrangler secret put WEB_ORIGINS --env staging
288
+ wrangler secret put WEB_ORIGINS
279
289
  ```
280
290
 
281
291
  ### 5.3 Responses
@@ -323,8 +333,8 @@ node apps/cf-worker/scripts/smoke.mjs
323
333
  ### 6.2 Iterate on the SPA config without rebuilding Kiwi
324
334
 
325
335
  The Kiwi upstream build is the slow step. For a config-only change,
326
- edit `static/config.staging.json`, then re-run
327
- `pnpm --filter web build:staging` — step 2 of the build reuses
336
+ edit `static/config.prod.json`, then re-run
337
+ `pnpm --filter web build:prod` — step 2 of the build reuses
328
338
  `upstream/node_modules` and skips straight to re-baking the config
329
339
  (steps 4–6 are fast).
330
340
 
@@ -502,8 +512,7 @@ git submodule update --init # docs + apps/web/upstream (Kiwi)
502
512
  pnpm install
503
513
 
504
514
  # Build the SPA + landing page (pick the env)
505
- pnpm --filter web build # default config (staging-shaped)
506
- pnpm --filter web build:staging # static/config.staging.json
515
+ pnpm --filter web build # default config (dev target)
507
516
  pnpm --filter web build:prod # static/config.prod.json
508
517
 
509
518
  # Local dev (Worker serves SPA + landing page + WS on one origin)
@@ -511,8 +520,8 @@ pnpm build # build the rest of the workspace
511
520
  pnpm --filter @serverless-ircd/cf-worker dev # http://localhost:8787
512
521
  node apps/cf-worker/scripts/smoke.mjs # WS smoke
513
522
 
514
- # Deploy staging (Worker + assets in one command)
515
- pnpm deploy:cf:staging # wrangler deploy --env staging
523
+ # Deploy (Worker + assets in one command)
524
+ pnpm deploy:cf # wrangler deploy
516
525
 
517
526
  # Tests / lint
518
527
  pnpm --filter web test
@@ -527,7 +536,7 @@ Key files:
527
536
  | `apps/web/scripts/build.mjs` | Orchestrates the upstream Kiwi build + layers our config. |
528
537
  | `apps/web/src/config-schema.ts` | Zod schema for `static/config.*.json` (the only TS we own). |
529
538
  | `apps/web/src/build-env.ts` | `--env` → config-file resolution (unit-tested). |
530
- | `apps/web/static/config.{,staging,prod}.json` | Per-env Kiwi configs (validated at build time). |
539
+ | `apps/web/static/config.{,prod}.json` | Kiwi configs (validated at build time). |
531
540
  | `apps/web/landing/index.html` | Static landing page (project front door), copied to `dist/index.html`. |
532
541
  | `apps/web/tests/build-smoke.test.ts` | Landing-page + build-output assertions. |
533
542
  | `apps/cf-worker/src/worker.ts` | Edge handler: WS → `ConnectionDO`, `/health`, else `env.ASSETS`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serverless-ircd",
3
- "version": "0.9.0",
3
+ "version": "0.11.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.9.0"
20
+ "@serverless-ircd/aws-adapter": "0.11.0"
21
21
  },
22
22
  "scripts": {
23
23
  "build": "turbo run build",
@@ -32,13 +32,10 @@
32
32
  "mutation:commands": "pnpm --filter @serverless-ircd/irc-core mutation:commands",
33
33
  "mutation": "pnpm run mutation:protocol && pnpm run mutation:commands",
34
34
  "clean": "turbo run clean && rimraf node_modules",
35
- "deploy:cf:staging": "pnpm --filter @serverless-ircd/cf-worker deploy:staging",
36
- "deploy:cf:prod": "pnpm --filter @serverless-ircd/cf-worker deploy:prod",
37
- "smoke:cf:staging": "pnpm --filter @serverless-ircd/cf-worker smoke:staging",
38
- "deploy:cf-tcp:staging": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:staging",
39
- "deploy:cf-tcp:prod": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:prod",
40
- "deploy:aws:staging": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:staging",
41
- "deploy:aws:prod": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:prod",
42
- "smoke:aws:staging": "pnpm --filter @serverless-ircd/aws-stack smoke:staging"
35
+ "deploy:cf": "pnpm build && pnpm --filter @serverless-ircd/cf-worker run deploy",
36
+ "smoke:cf": "pnpm --filter @serverless-ircd/cf-worker run smoke",
37
+ "deploy:cf-tcp": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container run deploy",
38
+ "deploy:aws": "pnpm build && pnpm --filter @serverless-ircd/aws-stack run deploy",
39
+ "smoke:aws": "pnpm --filter @serverless-ircd/aws-stack run smoke"
43
40
  }
44
41
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-adapter",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Pure admission policy for the AWS `$connect` handler.
3
3
  *
4
- * The I/O (counting live rows in DynamoDB) lives in `handlers/connect.ts`;
5
- * the *decision* "does this count fit under the caps?" — is a pure
6
- * function extracted here so it is trivial to unit-test every branch
7
- * without standing up DynamoDB Local.
4
+ * The I/O (counting live rows in DynamoDB) lives in `handlers/connect.ts`
5
+ * (`ip-admission.ts` for the per-IP walk, `connection-counter.ts` for the
6
+ * global counter); the *decision* "do these counts fit under the caps?"
7
+ * is a pure function extracted here so it is trivial to unit-test every
8
+ * branch without standing up DynamoDB Local.
8
9
  *
9
10
  * This is deliberately separate from irc-core's `decideAdmission` /
10
11
  * `AdmissionStats`: that helper is an in-memory counter design that
@@ -17,25 +18,30 @@
17
18
  /**
18
19
  * Live connection counts gathered from DynamoDB.
19
20
  *
20
- * - `total` is always supplied (a paginated `Scan COUNT` over
21
+ * - `total` is always supplied (the atomic meta-row counter over all
21
22
  * `Connections`).
22
- * - `perIp` is optional: counting connections per source IP needs a
23
- * GSI on `sourceIp` (see `handlers/connect.ts` for the deferred
24
- * per-IP follow-up). Until that lands, callers omit `perIp` and the
25
- * per-IP branch of {@link decideConnectAdmission} is skipped.
23
+ * - `perIp` / `recentPerIp` are supplied when the connecting client's
24
+ * source IP is known: `perIp` counts the IP's live connections (the
25
+ * `sourceIp` GSI) and `recentPerIp` counts those established inside
26
+ * the `perIpConnectionRate` window. Callers omit both when the
27
+ * event carries no source IP; the per-IP branches are then skipped.
26
28
  */
27
29
  export interface ConnectionCounts {
28
30
  readonly total: number;
29
31
  readonly perIp?: number;
32
+ readonly recentPerIp?: number;
30
33
  }
31
34
 
32
35
  /**
33
- * Effective caps sourced from {@link ParsedServerConfig}. `perIp` is
34
- * optional because per-IP enforcement is not yet wired on the AWS path.
36
+ * Effective caps sourced from {@link ParsedServerConfig}. The per-IP
37
+ * caps are optional because they only bind when the corresponding count
38
+ * is also available (see {@link ConnectionCounts}).
35
39
  */
36
40
  export interface AdmissionLimits {
37
41
  readonly maxClients: number;
38
42
  readonly maxConnectionsPerIp?: number;
43
+ /** Per-IP connection-rate budget (`perIpConnectionRate`). */
44
+ readonly perIpRate?: { readonly max: number; readonly windowMs: number };
39
45
  }
40
46
 
41
47
  /**
@@ -53,8 +59,10 @@ export type AdmissionOutcome =
53
59
  * ambient state.
54
60
  *
55
61
  * Precedence: the global `maxClients` cap is checked first (it is the
56
- * cheap, always-available gate), then the optional per-IP cap when both
57
- * the limit and the `perIp` count are present.
62
+ * cheap, always-available gate), then the optional per-IP simultaneous
63
+ * cap, then the optional per-IP rate window matching the per-cap
64
+ * ordering of irc-core's single-process `decideAdmission` (global first
65
+ * here because its count is O(1)-available, unlike there).
58
66
  */
59
67
  export function decideConnectAdmission(
60
68
  counts: ConnectionCounts,
@@ -70,5 +78,12 @@ export function decideConnectAdmission(
70
78
  ) {
71
79
  return { admitted: false, statusCode: 429, reason: 'too many connections from this IP' };
72
80
  }
81
+ if (
82
+ limits.perIpRate !== undefined &&
83
+ counts.recentPerIp !== undefined &&
84
+ counts.recentPerIp >= limits.perIpRate.max
85
+ ) {
86
+ return { admitted: false, statusCode: 429, reason: 'connection rate exceeded for this IP' };
87
+ }
73
88
  return { admitted: true };
74
89
  }
@@ -239,15 +239,42 @@ export class AwsRuntime implements IrcRuntime {
239
239
  * phase — the APIGW call count is bounded by the genuine recipient set.
240
240
  */
241
241
  async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
242
+ await this.fanOutByUserMode(lines, except, 'wallops');
243
+ }
244
+
245
+ /**
246
+ * Oper-only notice fanout (the OPER reducer's per-IP lockout notice).
247
+ * Identical enumeration to {@link broadcastWallops} but the recipient
248
+ * gate is user mode `+o`: a filtered `Scan` of the `Connections` table
249
+ * followed by a `PostToConnection` per oper. The same cost note applies
250
+ * — the scan reads the whole table — but lockout notices only fire when
251
+ * a host trips the failed-OPER budget, so the rate is bounded by
252
+ * attacker behaviour, not by legitimate traffic.
253
+ */
254
+ async broadcastOperNotice(lines: RawLine[], except?: ConnId): Promise<void> {
255
+ await this.fanOutByUserMode(lines, except, 'oper');
256
+ }
257
+
258
+ /**
259
+ * Shared fan-out for {@link broadcastWallops} / {@link broadcastOperNotice}:
260
+ * paginated filtered `Scan` over `userModes.<mode>`, skipping `except`,
261
+ * delivering via `send` (which short-cuts the bound connection through
262
+ * the in-process handlers and tolerates vanished sockets).
263
+ */
264
+ private async fanOutByUserMode(
265
+ lines: RawLine[],
266
+ except: ConnId | undefined,
267
+ mode: 'wallops' | 'oper',
268
+ ): Promise<void> {
242
269
  let startKey: Record<string, NativeAttributeValue> | undefined;
243
270
  // Paginate the scan: DynamoDB caps a single Scan at 1 MB. Loop until
244
- // no `LastEvaluatedKey` remains so every +w connection is reached.
271
+ // no `LastEvaluatedKey` remains so every matching connection is reached.
245
272
  do {
246
273
  const result = await this.dynamo.send(
247
274
  new ScanCommand({
248
275
  TableName: this.tables.Connections,
249
276
  FilterExpression: 'userModes.#m = :true',
250
- ExpressionAttributeNames: { '#m': 'wallops' },
277
+ ExpressionAttributeNames: { '#m': mode },
251
278
  ExpressionAttributeValues: { ':true': true },
252
279
  ...(startKey !== undefined ? { ExclusiveStartKey: startKey } : {}),
253
280
  }),
@@ -259,7 +286,7 @@ export class AwsRuntime implements IrcRuntime {
259
286
  // `send` short-cuts the bound connection through the in-process
260
287
  // handlers and catches GoneException so vanished sockets are
261
288
  // cleaned up rather than aborting the fan-out mid-loop.
262
- if (row.userModes.wallops) {
289
+ if (mode === 'wallops' ? row.userModes.wallops : row.userModes.oper) {
263
290
  await this.send(connId, lines);
264
291
  }
265
292
  }