serverless-ircd 0.6.0 → 0.8.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.
- package/.github/workflows/ci.yml +9 -4
- package/.gitmodules +4 -1
- package/CHANGELOG.md +480 -22
- package/README.md +244 -57
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +186 -18
- package/apps/aws-stack/tests/stack.test.ts +400 -56
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/src/origin-allowlist.ts +99 -0
- package/apps/cf-worker/src/worker.ts +89 -9
- package/apps/cf-worker/tests/fixtures/web-dist/index.html +18 -0
- package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
- package/apps/cf-worker/tests/origin-allowlist.test.ts +101 -0
- package/apps/cf-worker/tests/smoke.test.ts +28 -2
- package/apps/cf-worker/tests/ws-origin.test.ts +89 -0
- package/apps/cf-worker/wrangler.test.toml +23 -0
- package/apps/cf-worker/wrangler.toml +56 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +8 -0
- package/apps/local-cli/src/main.ts +16 -0
- package/apps/local-cli/src/server.ts +1 -0
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/web/landing/index.html +215 -0
- package/apps/web/package.json +28 -0
- package/apps/web/scripts/build.mjs +132 -0
- package/apps/web/src/build-env.ts +43 -0
- package/apps/web/src/config-schema.ts +138 -0
- package/apps/web/static/config.json +28 -0
- package/apps/web/static/config.prod.json +28 -0
- package/apps/web/static/config.staging.json +28 -0
- package/apps/web/tests/build-env.test.ts +63 -0
- package/apps/web/tests/build-smoke.test.ts +103 -0
- package/apps/web/tests/config-schema.test.ts +432 -0
- package/apps/web/tests/workspace.test.ts +12 -0
- package/apps/web/tsconfig.json +10 -0
- package/apps/web/upstream/.browserslistrc +3 -0
- package/apps/web/upstream/.editorconfig +9 -0
- package/apps/web/upstream/.eslintignore +3 -0
- package/apps/web/upstream/.eslintrc.js +90 -0
- package/apps/web/upstream/.github/FUNDING.yml +12 -0
- package/apps/web/upstream/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
- package/apps/web/upstream/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- package/apps/web/upstream/.github/workflows/push.yml +26 -0
- package/apps/web/upstream/.github/workflows/release.yml +33 -0
- package/apps/web/upstream/.github/workflows/release_manual.yml +55 -0
- package/apps/web/upstream/.prettierrc.js +9 -0
- package/apps/web/upstream/.stylelintignore +5 -0
- package/apps/web/upstream/.stylelintrc.js +21 -0
- package/apps/web/upstream/LICENSE +201 -0
- package/apps/web/upstream/README.md +76 -0
- package/apps/web/upstream/babel.config.js +25 -0
- package/apps/web/upstream/crowdin.yml +3 -0
- package/apps/web/upstream/index.html +35 -0
- package/apps/web/upstream/jest.config.js +7 -0
- package/apps/web/upstream/jsdoc.json +22 -0
- package/apps/web/upstream/package.json +80 -0
- package/apps/web/upstream/scripts/pre-commit +43 -0
- package/apps/web/upstream/src/components/App.vue +516 -0
- package/apps/web/upstream/src/components/AppSettings.vue +587 -0
- package/apps/web/upstream/src/components/AutoComplete.vue +284 -0
- package/apps/web/upstream/src/components/Avatar.vue +105 -0
- package/apps/web/upstream/src/components/AwayStatusIndicator.vue +77 -0
- package/apps/web/upstream/src/components/BufferKey.vue +121 -0
- package/apps/web/upstream/src/components/BufferSettings.vue +97 -0
- package/apps/web/upstream/src/components/Captcha.vue +85 -0
- package/apps/web/upstream/src/components/ChannelBanlist.vue +96 -0
- package/apps/web/upstream/src/components/ChannelInfo.vue +159 -0
- package/apps/web/upstream/src/components/ChannelInvitelist.vue +276 -0
- package/apps/web/upstream/src/components/ChannelList.vue +345 -0
- package/apps/web/upstream/src/components/Container.vue +401 -0
- package/apps/web/upstream/src/components/ContainerHeader.vue +522 -0
- package/apps/web/upstream/src/components/ControlInput.vue +1016 -0
- package/apps/web/upstream/src/components/LoadingAnimation.vue +104 -0
- package/apps/web/upstream/src/components/MediaViewer.vue +199 -0
- package/apps/web/upstream/src/components/MessageInfo.vue +192 -0
- package/apps/web/upstream/src/components/MessageList.vue +1028 -0
- package/apps/web/upstream/src/components/MessageListAvatar.vue +20 -0
- package/apps/web/upstream/src/components/MessageListMessageCompact.vue +361 -0
- package/apps/web/upstream/src/components/MessageListMessageInline.vue +257 -0
- package/apps/web/upstream/src/components/MessageListMessageModern.vue +430 -0
- package/apps/web/upstream/src/components/NetworkSettings.vue +528 -0
- package/apps/web/upstream/src/components/Nicklist.vue +309 -0
- package/apps/web/upstream/src/components/NicklistUser.vue +181 -0
- package/apps/web/upstream/src/components/NotConnected.vue +271 -0
- package/apps/web/upstream/src/components/SelfUser.vue +283 -0
- package/apps/web/upstream/src/components/ServerSelector.vue +162 -0
- package/apps/web/upstream/src/components/ServerView.vue +126 -0
- package/apps/web/upstream/src/components/SettingsAdvanced.vue +232 -0
- package/apps/web/upstream/src/components/SettingsAliases.vue +121 -0
- package/apps/web/upstream/src/components/Sidebar.vue +429 -0
- package/apps/web/upstream/src/components/SidebarAboutBuffer.vue +288 -0
- package/apps/web/upstream/src/components/SidebarState.vue +145 -0
- package/apps/web/upstream/src/components/StartupError.vue +35 -0
- package/apps/web/upstream/src/components/StateBrowser.vue +420 -0
- package/apps/web/upstream/src/components/StateBrowserBuffer.vue +124 -0
- package/apps/web/upstream/src/components/StateBrowserNetwork.vue +823 -0
- package/apps/web/upstream/src/components/StateBrowserUsermenu.vue +162 -0
- package/apps/web/upstream/src/components/TypingStatusIndicator.vue +34 -0
- package/apps/web/upstream/src/components/TypingUsersList.vue +56 -0
- package/apps/web/upstream/src/components/UrlEmbed.vue +149 -0
- package/apps/web/upstream/src/components/UserBox.vue +728 -0
- package/apps/web/upstream/src/components/inputtools/Emoji.vue +67 -0
- package/apps/web/upstream/src/components/inputtools/TextStyle.vue +142 -0
- package/apps/web/upstream/src/components/startups/CommonLayout.vue +208 -0
- package/apps/web/upstream/src/components/startups/CustomServer.vue +495 -0
- package/apps/web/upstream/src/components/startups/KiwiBnc.vue +361 -0
- package/apps/web/upstream/src/components/startups/Personal.vue +317 -0
- package/apps/web/upstream/src/components/startups/Welcome.vue +544 -0
- package/apps/web/upstream/src/components/startups/ZncLogin.vue +229 -0
- package/apps/web/upstream/src/components/utils/InputConfirm.vue +68 -0
- package/apps/web/upstream/src/components/utils/InputPrompt.vue +118 -0
- package/apps/web/upstream/src/components/utils/InputText.vue +172 -0
- package/apps/web/upstream/src/components/utils/IrcInput.vue +579 -0
- package/apps/web/upstream/src/components/utils/PluginWrapper.vue +26 -0
- package/apps/web/upstream/src/components/utils/TabbedView.vue +149 -0
- package/apps/web/upstream/src/components/utils/TransitionExpand.vue +97 -0
- package/apps/web/upstream/src/helpers/Colours.js +128 -0
- package/apps/web/upstream/src/helpers/IrcdDiffs.js +26 -0
- package/apps/web/upstream/src/helpers/Md5.js +193 -0
- package/apps/web/upstream/src/helpers/Misc.js +401 -0
- package/apps/web/upstream/src/helpers/TextFormatting.js +237 -0
- package/apps/web/upstream/src/libs/AliasRewriter.js +157 -0
- package/apps/web/upstream/src/libs/AudioManager.js +60 -0
- package/apps/web/upstream/src/libs/BouncerMiddleware.js +247 -0
- package/apps/web/upstream/src/libs/BouncerProvider.js +551 -0
- package/apps/web/upstream/src/libs/ChathistoryMiddleware.js +153 -0
- package/apps/web/upstream/src/libs/ConfigLoader.js +94 -0
- package/apps/web/upstream/src/libs/EmojiProvider.js +46 -0
- package/apps/web/upstream/src/libs/GlobalApi.js +346 -0
- package/apps/web/upstream/src/libs/IPC.js +51 -0
- package/apps/web/upstream/src/libs/InputHandler.js +921 -0
- package/apps/web/upstream/src/libs/IrcClient.js +1506 -0
- package/apps/web/upstream/src/libs/Logger.js +71 -0
- package/apps/web/upstream/src/libs/Message.js +169 -0
- package/apps/web/upstream/src/libs/MessageFormatter.js +364 -0
- package/apps/web/upstream/src/libs/MessageParser.js +249 -0
- package/apps/web/upstream/src/libs/Notifications.js +78 -0
- package/apps/web/upstream/src/libs/ServerConnection.js +270 -0
- package/apps/web/upstream/src/libs/ServerSession.js +102 -0
- package/apps/web/upstream/src/libs/SoundBleep.js +20 -0
- package/apps/web/upstream/src/libs/StatePersistence.js +90 -0
- package/apps/web/upstream/src/libs/ThemeManager.js +128 -0
- package/apps/web/upstream/src/libs/TypingMiddleware.js +105 -0
- package/apps/web/upstream/src/libs/WindowTitle.js +63 -0
- package/apps/web/upstream/src/libs/batchedAdd.js +74 -0
- package/apps/web/upstream/src/libs/bufferTools.js +177 -0
- package/apps/web/upstream/src/libs/polyfill/Element.closest.js +18 -0
- package/apps/web/upstream/src/libs/renderers/Html.js +113 -0
- package/apps/web/upstream/src/libs/settingTools.js +31 -0
- package/apps/web/upstream/src/libs/state/BufferState.js +703 -0
- package/apps/web/upstream/src/libs/state/NetworkState.js +158 -0
- package/apps/web/upstream/src/libs/state/UserState.js +89 -0
- package/apps/web/upstream/src/libs/state/common.js +26 -0
- package/apps/web/upstream/src/libs/state.js +961 -0
- package/apps/web/upstream/src/libs/storage/Local.js +51 -0
- package/apps/web/upstream/src/main.js +566 -0
- package/apps/web/upstream/src/res/autocompleteCommands.js +31 -0
- package/apps/web/upstream/src/res/configTemplates.js +373 -0
- package/apps/web/upstream/src/res/globalStyle.css +277 -0
- package/apps/web/upstream/src/res/kiwiLoadingLogo.png +0 -0
- package/apps/web/upstream/src/res/locales/app.af-ZA.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ar-SA.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.bg-BG.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.bs-BA.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ca-ES.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.cs-CZ.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.da-DK.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.de-DE.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.dev.po +1246 -0
- package/apps/web/upstream/src/res/locales/app.el-GR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.en-US.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.es-419.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.es-AR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.es-EM.po +536 -0
- package/apps/web/upstream/src/res/locales/app.es-ES.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.es-US.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.eu-ES.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.fi-FI.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.fr-FR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.gl-ES.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.he-IL.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.hi-IN.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.hu-HU.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.id-ID.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.it-IT.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ja-JP.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ko-KR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.nl-NL.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.no-NO.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.pl-PL.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.pt-BR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.pt-PT.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ro-RO.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.ru-RU.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.sl-SI.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.sq-AL.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.sr-SP.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.sv-SE.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.tr-TR.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.uk-UA.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.vi-VN.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.zh-CN.po +1145 -0
- package/apps/web/upstream/src/res/locales/app.zh-TW.po +1145 -0
- package/apps/web/upstream/src/res/localesList.json +27 -0
- package/apps/web/upstream/src/res/logo.png +0 -0
- package/apps/web/upstream/src/thirdparty/about.html +43 -0
- package/apps/web/upstream/src/thirdparty/index.js +5 -0
- package/apps/web/upstream/src/thirdparty/kiwiirccom.vue +185 -0
- package/apps/web/upstream/static/config.json +28 -0
- package/apps/web/upstream/static/emoticons/smile.png +0 -0
- package/apps/web/upstream/static/favicon.png +0 -0
- package/apps/web/upstream/static/highlight.mp3 +0 -0
- package/apps/web/upstream/static/highlight.ogg +0 -0
- package/apps/web/upstream/static/locales/.gitignore +2 -0
- package/apps/web/upstream/static/plugins/customise.html.example +10 -0
- package/apps/web/upstream/static/themes/coffee/theme.css +145 -0
- package/apps/web/upstream/static/themes/common/base.css +1064 -0
- package/apps/web/upstream/static/themes/dark/theme.css +196 -0
- package/apps/web/upstream/static/themes/default/theme.css +92 -0
- package/apps/web/upstream/static/themes/elite/theme.css +248 -0
- package/apps/web/upstream/static/themes/grayfox/theme.css +143 -0
- package/apps/web/upstream/static/themes/nightswatch/theme.css +367 -0
- package/apps/web/upstream/static/themes/osprey/theme.css +115 -0
- package/apps/web/upstream/static/themes/radioactive/theme.css +1211 -0
- package/apps/web/upstream/static/themes/sky/theme.css +107 -0
- package/apps/web/upstream/tests/unit/BatchAdd.spec.js +174 -0
- package/apps/web/upstream/tests/unit/MessageParser.spec.js +125 -0
- package/apps/web/upstream/tests/unit/Misc.spec.js +24 -0
- package/apps/web/upstream/tests/unit/NetworkState.spec.js +58 -0
- package/apps/web/upstream/tests/unit/StartupError.spec.js +20 -0
- package/apps/web/upstream/vue.config.js +150 -0
- package/apps/web/upstream/yarn.lock +9767 -0
- package/apps/web/vitest.config.ts +13 -0
- package/biome.json +1 -0
- package/docs/AWS-Deployment.md +21 -8
- package/docs/Cloudflare-Deployment-Guide.md +22 -6
- package/docs/Home.md +1 -0
- package/docs/PlanExtensions.md +113 -3
- package/docs/Release-Process.md +23 -13
- package/docs/Services.md +546 -0
- package/docs/WebClientGuide.md +536 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +5 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
- package/packages/aws-adapter/src/config-loader.ts +11 -0
- package/packages/aws-adapter/src/connection-counter.ts +89 -0
- package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
- package/packages/aws-adapter/src/handlers/connect.ts +55 -51
- package/packages/aws-adapter/src/handlers/default.ts +36 -4
- package/packages/aws-adapter/src/handlers/index.ts +15 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
- package/packages/aws-adapter/src/index.ts +4 -0
- package/packages/aws-adapter/src/stats.ts +6 -1
- package/packages/aws-adapter/src/tables.ts +34 -4
- package/packages/aws-adapter/tests/aws-harness.ts +3 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
- package/packages/aws-adapter/tests/connect.test.ts +158 -32
- package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
- package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
- package/packages/aws-adapter/tests/handlers.test.ts +105 -3
- package/packages/aws-adapter/tests/tables.test.ts +6 -1
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/config-loader.ts +11 -0
- package/packages/cf-adapter/src/connection-do.ts +112 -2
- package/packages/cf-adapter/src/d1-services-store.ts +703 -0
- package/packages/cf-adapter/src/env.ts +8 -0
- package/packages/cf-adapter/src/index.ts +5 -0
- package/packages/cf-adapter/tests/cf-harness.ts +12 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
- package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
- package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
- package/packages/cf-adapter/tests/serialize.test.ts +1 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/reports/mutation/mutation.html +342 -0
- package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
- package/packages/irc-core/src/commands/account-auth.ts +172 -0
- package/packages/irc-core/src/commands/chanserv.ts +882 -0
- package/packages/irc-core/src/commands/hostserv.ts +487 -0
- package/packages/irc-core/src/commands/index.ts +12 -0
- package/packages/irc-core/src/commands/join.ts +164 -8
- package/packages/irc-core/src/commands/markread.ts +202 -0
- package/packages/irc-core/src/commands/memoserv.ts +319 -0
- package/packages/irc-core/src/commands/mode.ts +96 -4
- package/packages/irc-core/src/commands/nickserv.ts +390 -0
- package/packages/irc-core/src/commands/oper.ts +18 -1
- package/packages/irc-core/src/commands/operserv.ts +346 -0
- package/packages/irc-core/src/commands/pre-away.ts +3 -1
- package/packages/irc-core/src/commands/privmsg.ts +42 -0
- package/packages/irc-core/src/commands/read-marker.ts +8 -8
- package/packages/irc-core/src/commands/registration.ts +61 -6
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +37 -0
- package/packages/irc-core/src/config.ts +36 -5
- package/packages/irc-core/src/effects.ts +56 -1
- package/packages/irc-core/src/ports.ts +1653 -84
- package/packages/irc-core/src/protocol/numerics.ts +8 -0
- package/packages/irc-core/src/state/channel.ts +21 -1
- package/packages/irc-core/src/state/connection.ts +25 -1
- package/packages/irc-core/src/types.ts +48 -12
- package/packages/irc-core/tests/batch.test.ts +15 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
- package/packages/irc-core/tests/commands/join.test.ts +393 -1
- package/packages/irc-core/tests/commands/markread.test.ts +361 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
- package/packages/irc-core/tests/commands/mode.test.ts +381 -2
- package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
- package/packages/irc-core/tests/commands/oper.test.ts +13 -0
- package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
- package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
- package/packages/irc-core/tests/commands/registration.test.ts +788 -14
- package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
- package/packages/irc-core/tests/commands/topic.test.ts +94 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
- package/packages/irc-core/tests/config.test.ts +49 -5
- package/packages/irc-core/tests/effects.test.ts +19 -0
- package/packages/irc-core/tests/message-store.test.ts +63 -0
- package/packages/irc-core/tests/numerics.test.ts +13 -0
- package/packages/irc-core/tests/parser.test.ts +109 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
- package/packages/irc-core/tests/services-store.test.ts +1289 -0
- package/packages/irc-core/tests/state/channel.test.ts +3 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +71 -16
- package/packages/irc-server/src/dispatch.ts +94 -7
- package/packages/irc-server/src/routing.ts +19 -0
- package/packages/irc-server/tests/actor.test.ts +623 -12
- package/packages/irc-server/tests/dispatch.test.ts +270 -2
- package/packages/irc-server/tests/routing.test.ts +6 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/src/scenarios.ts +24 -2
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
- package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the OperServ services pseudo-client.
|
|
3
|
+
*
|
|
4
|
+
* OperServ is invoked via `PRIVMSG OperServ :<command>`. The actor routes
|
|
5
|
+
* any `PRIVMSG` whose target matches {@link OPERSERV_NICK}
|
|
6
|
+
* (case-insensitive) to {@link operservReducer} instead of the normal
|
|
7
|
+
* user-target reducer.
|
|
8
|
+
*
|
|
9
|
+
* Every subcommand requires oper privileges (`state.userModes.oper`); a
|
|
10
|
+
* non-oper is rejected with a NOTICE and no state change.
|
|
11
|
+
*
|
|
12
|
+
* Supported subcommands:
|
|
13
|
+
* - `AKILL ADD <mask> <reason…>` — records a network-wide ban against
|
|
14
|
+
* the bound {@link ServicesStore}. The admission path consults
|
|
15
|
+
* {@link checkAkillAdmission} on every new connection (and on every
|
|
16
|
+
* PRIVMSG via the actor layer) and force-disconnects on match.
|
|
17
|
+
* - `AKILL DEL <mask>` — removes the AKILL with the matching mask
|
|
18
|
+
* (case-insensitive).
|
|
19
|
+
* - `AKILL LIST` — enumerates every recorded AKILL.
|
|
20
|
+
* - `JUPE <nick> [reason…]` — blocks a nick / server name locally. The
|
|
21
|
+
* NICK reducer consults {@link enforceJupedNick} on every NICK change
|
|
22
|
+
* and returns `432 ERR_ERRONEUSNICKNAME` for a jupe'd target.
|
|
23
|
+
* - `UNJUPE <nick>` — clears the matching jupe.
|
|
24
|
+
* - `RAW <line>` — oper-only; emits the raw IRC line as a Send effect
|
|
25
|
+
* (interpreted by the actor as a raw injection) and confirms with a
|
|
26
|
+
* NOTICE. Recovery use only.
|
|
27
|
+
*
|
|
28
|
+
* The reducer is a pure `Reducer<ConnectionState>`: all side effects are
|
|
29
|
+
* emitted as {@link Effect}s. AKILL / JUPE persistence lives in the
|
|
30
|
+
* synchronous {@link ServicesStore}.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { Effect } from '../effects.js';
|
|
34
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
35
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
36
|
+
import { hostmaskOf } from '../state/connection.js';
|
|
37
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
38
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
39
|
+
|
|
40
|
+
/** Canonical OperServ pseudo-client nick. */
|
|
41
|
+
export const OPERSERV_NICK = 'OperServ';
|
|
42
|
+
|
|
43
|
+
/** Services hostmask fragment used by every services pseudo-client. */
|
|
44
|
+
const SERVICES_HOST = 'services';
|
|
45
|
+
|
|
46
|
+
/** Canonical disconnect reason applied by the AKILL admission hook. */
|
|
47
|
+
export const AKILL_DISCONNECT_REASON = 'AKILL: network-wide ban';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reducer for `PRIVMSG OperServ :<subcommand> <args…>`.
|
|
51
|
+
* Authority: the invoking connection's {@link ConnectionState}.
|
|
52
|
+
*/
|
|
53
|
+
export const operservReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
54
|
+
state.lastSeen = ctx.clock.now();
|
|
55
|
+
|
|
56
|
+
const effects: EffectType[] = [];
|
|
57
|
+
|
|
58
|
+
if (ctx.services === undefined) {
|
|
59
|
+
effects.push(notice(state, 'Services are not available on this server.'));
|
|
60
|
+
return { state, effects };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const body = msg.params[1] ?? '';
|
|
64
|
+
const tokens = body.split(/\s+/u).filter((t) => t.length > 0);
|
|
65
|
+
if (tokens.length === 0) {
|
|
66
|
+
effects.push(helpNotice(state));
|
|
67
|
+
return { state, effects };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Oper gate: every subcommand requires oper privileges. Reject without
|
|
71
|
+
// mutating services state so a non-oper cannot add AKILLs / JUPEs.
|
|
72
|
+
if (!state.userModes.oper) {
|
|
73
|
+
effects.push(notice(state, 'Permission denied — oper privileges required.'));
|
|
74
|
+
return { state, effects };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sub = tokens[0]?.toUpperCase();
|
|
78
|
+
const args = tokens.slice(1);
|
|
79
|
+
|
|
80
|
+
switch (sub) {
|
|
81
|
+
case 'AKILL':
|
|
82
|
+
return handleAkill(state, args, ctx, effects);
|
|
83
|
+
case 'JUPE':
|
|
84
|
+
return handleJupe(state, args, ctx, effects);
|
|
85
|
+
case 'UNJUPE':
|
|
86
|
+
return handleUnjupe(state, args, ctx, effects);
|
|
87
|
+
case 'RAW':
|
|
88
|
+
return handleRaw(state, args, effects);
|
|
89
|
+
default:
|
|
90
|
+
effects.push(unknownNotice(state));
|
|
91
|
+
return { state, effects };
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// AKILL ADD / DEL / LIST
|
|
97
|
+
// ============================================================================
|
|
98
|
+
|
|
99
|
+
function handleAkill(
|
|
100
|
+
state: ConnectionState,
|
|
101
|
+
args: string[],
|
|
102
|
+
ctx: Ctx,
|
|
103
|
+
effects: EffectType[],
|
|
104
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
105
|
+
const services = ctx.services as NonNullable<Ctx['services']>;
|
|
106
|
+
const action = args[0]?.toUpperCase();
|
|
107
|
+
const rest = args.slice(1);
|
|
108
|
+
|
|
109
|
+
switch (action) {
|
|
110
|
+
case 'ADD':
|
|
111
|
+
return handleAkillAdd(state, rest, services, effects);
|
|
112
|
+
case 'DEL':
|
|
113
|
+
return handleAkillDel(state, rest, services, effects);
|
|
114
|
+
case 'LIST':
|
|
115
|
+
return handleAkillList(state, services, effects);
|
|
116
|
+
default:
|
|
117
|
+
effects.push(notice(state, 'Syntax: AKILL ADD|DEL|LIST [args]'));
|
|
118
|
+
return { state, effects };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function handleAkillAdd(
|
|
123
|
+
state: ConnectionState,
|
|
124
|
+
args: string[],
|
|
125
|
+
services: NonNullable<Ctx['services']>,
|
|
126
|
+
effects: EffectType[],
|
|
127
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
128
|
+
const mask = args[0];
|
|
129
|
+
const reasonTokens = args.slice(1);
|
|
130
|
+
if (mask === undefined || reasonTokens.length === 0) {
|
|
131
|
+
effects.push(notice(state, 'Syntax: AKILL ADD <mask> <reason>'));
|
|
132
|
+
return { state, effects };
|
|
133
|
+
}
|
|
134
|
+
const reason = reasonTokens.join(' ');
|
|
135
|
+
services.addAkill(mask, reason);
|
|
136
|
+
effects.push(notice(state, `AKILL ${mask} added (reason: ${reason}).`));
|
|
137
|
+
return { state, effects };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function handleAkillDel(
|
|
141
|
+
state: ConnectionState,
|
|
142
|
+
args: string[],
|
|
143
|
+
services: NonNullable<Ctx['services']>,
|
|
144
|
+
effects: EffectType[],
|
|
145
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
146
|
+
const mask = args[0];
|
|
147
|
+
if (mask === undefined) {
|
|
148
|
+
effects.push(notice(state, 'Syntax: AKILL DEL <mask>'));
|
|
149
|
+
return { state, effects };
|
|
150
|
+
}
|
|
151
|
+
if (services.removeAkill(mask)) {
|
|
152
|
+
effects.push(notice(state, `AKILL ${mask} removed.`));
|
|
153
|
+
return { state, effects };
|
|
154
|
+
}
|
|
155
|
+
effects.push(notice(state, `No AKILL matching ${mask}.`));
|
|
156
|
+
return { state, effects };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function handleAkillList(
|
|
160
|
+
state: ConnectionState,
|
|
161
|
+
services: NonNullable<Ctx['services']>,
|
|
162
|
+
effects: EffectType[],
|
|
163
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
164
|
+
const entries = services.listAkills();
|
|
165
|
+
if (entries.length === 0) {
|
|
166
|
+
effects.push(notice(state, 'No AKILLs are currently set.'));
|
|
167
|
+
return { state, effects };
|
|
168
|
+
}
|
|
169
|
+
for (const entry of entries) {
|
|
170
|
+
effects.push(notice(state, `${entry.mask} — ${entry.reason}`));
|
|
171
|
+
}
|
|
172
|
+
effects.push(notice(state, 'End of AKILL list.'));
|
|
173
|
+
return { state, effects };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ============================================================================
|
|
177
|
+
// JUPE / UNJUPE
|
|
178
|
+
// ============================================================================
|
|
179
|
+
|
|
180
|
+
function handleJupe(
|
|
181
|
+
state: ConnectionState,
|
|
182
|
+
args: string[],
|
|
183
|
+
ctx: Ctx,
|
|
184
|
+
effects: EffectType[],
|
|
185
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
186
|
+
const services = ctx.services as NonNullable<Ctx['services']>;
|
|
187
|
+
const name = args[0];
|
|
188
|
+
if (name === undefined) {
|
|
189
|
+
effects.push(notice(state, 'Syntax: JUPE <nick> [reason]'));
|
|
190
|
+
return { state, effects };
|
|
191
|
+
}
|
|
192
|
+
const reasonTokens = args.slice(1);
|
|
193
|
+
if (reasonTokens.length === 0) {
|
|
194
|
+
services.addJupe(name);
|
|
195
|
+
effects.push(notice(state, `JUPE ${name} added.`));
|
|
196
|
+
return { state, effects };
|
|
197
|
+
}
|
|
198
|
+
const reason = reasonTokens.join(' ');
|
|
199
|
+
services.addJupe(name, reason);
|
|
200
|
+
effects.push(notice(state, `JUPE ${name} added (reason: ${reason}).`));
|
|
201
|
+
return { state, effects };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function handleUnjupe(
|
|
205
|
+
state: ConnectionState,
|
|
206
|
+
args: string[],
|
|
207
|
+
ctx: Ctx,
|
|
208
|
+
effects: EffectType[],
|
|
209
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
210
|
+
const services = ctx.services as NonNullable<Ctx['services']>;
|
|
211
|
+
const name = args[0];
|
|
212
|
+
if (name === undefined) {
|
|
213
|
+
effects.push(notice(state, 'Syntax: UNJUPE <nick>'));
|
|
214
|
+
return { state, effects };
|
|
215
|
+
}
|
|
216
|
+
if (services.removeJupe(name)) {
|
|
217
|
+
effects.push(notice(state, `JUPE ${name} removed.`));
|
|
218
|
+
return { state, effects };
|
|
219
|
+
}
|
|
220
|
+
effects.push(notice(state, `No JUPE matching ${name}.`));
|
|
221
|
+
return { state, effects };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ============================================================================
|
|
225
|
+
// RAW
|
|
226
|
+
// ============================================================================
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Handles `RAW <line>`. The line is emitted back to the oper via a Send
|
|
230
|
+
* effect (the actor layer interprets this as a raw injection; for the
|
|
231
|
+
* in-memory runtime it is observable verbatim), and a confirmation
|
|
232
|
+
* NOTICE follows. Logged at the dispatch layer via the standard
|
|
233
|
+
* observability surface.
|
|
234
|
+
*/
|
|
235
|
+
function handleRaw(
|
|
236
|
+
state: ConnectionState,
|
|
237
|
+
args: string[],
|
|
238
|
+
effects: EffectType[],
|
|
239
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
240
|
+
if (args.length === 0) {
|
|
241
|
+
effects.push(notice(state, 'Syntax: RAW <line>'));
|
|
242
|
+
return { state, effects };
|
|
243
|
+
}
|
|
244
|
+
const line = args.join(' ');
|
|
245
|
+
const raw: RawLine = { text: line };
|
|
246
|
+
effects.push(Effect.send(state.id, [raw]));
|
|
247
|
+
effects.push(notice(state, 'RAW line accepted and logged.'));
|
|
248
|
+
return { state, effects };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ============================================================================
|
|
252
|
+
// Admission hook — AKILL
|
|
253
|
+
// ============================================================================
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Returns the effects to emit when a connection's hostmask is first
|
|
257
|
+
* computable (at the `$connect` / admission boundary). If the hostmask
|
|
258
|
+
* matches a recorded AKILL, the connection is force-disconnected via a
|
|
259
|
+
* {@link Effect.disconnect} (with a NOTICE explaining the ban). Returns
|
|
260
|
+
* `[]` when no services store is bound, the connection has no hostmask
|
|
261
|
+
* yet, or no AKILL matches.
|
|
262
|
+
*
|
|
263
|
+
* Called by the actor layer once the connection has a nick!user@host to
|
|
264
|
+
* check (post-registration / pre-welcome). Pure: the only input is the
|
|
265
|
+
* connection state + ctx; the only output is the effect list.
|
|
266
|
+
*/
|
|
267
|
+
export function checkAkillAdmission(state: ConnectionState, ctx: Ctx): EffectType[] {
|
|
268
|
+
const services = ctx.services;
|
|
269
|
+
if (services === undefined) return [];
|
|
270
|
+
const hostmask = hostmaskOf(state);
|
|
271
|
+
if (hostmask === undefined) return [];
|
|
272
|
+
const hit = services.matchAkill(hostmask);
|
|
273
|
+
if (hit === undefined) return [];
|
|
274
|
+
// `hostmaskOf` returns undefined when `nick` is undefined; we returned
|
|
275
|
+
// early above on the undefined-hostmask path, so `state.nick` is
|
|
276
|
+
// guaranteed defined here.
|
|
277
|
+
// biome-ignore lint/style/noNonNullAssertion: invariant — see above.
|
|
278
|
+
const nick = state.nick!;
|
|
279
|
+
const noticeLine: RawLine = {
|
|
280
|
+
text: `:${OPERSERV_NICK}!${OPERSERV_NICK}@${SERVICES_HOST} NOTICE ${nick} :You are banned from this network (AKILL: ${hit.reason}).`,
|
|
281
|
+
};
|
|
282
|
+
return [
|
|
283
|
+
Effect.send(state.id, [noticeLine]),
|
|
284
|
+
Effect.disconnect(state.id, AKILL_DISCONNECT_REASON),
|
|
285
|
+
];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ============================================================================
|
|
289
|
+
// JUPE enforcement hook — NICK reducer
|
|
290
|
+
// ============================================================================
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Returns the effects to emit when a client has just taken (or attempted
|
|
294
|
+
* to take) `nick`: if the nick is juped, returns a single
|
|
295
|
+
* `432 ERR_ERRONEUSNICKNAME` Send effect addressed to the connection.
|
|
296
|
+
* Returns `[]` when no services store is bound, the nick is undefined,
|
|
297
|
+
* or the nick is not juped.
|
|
298
|
+
*
|
|
299
|
+
* Called by the NICK reducer on every nick-change attempt so a jupe'd
|
|
300
|
+
* target surfaces the same numeric an invalid nick would.
|
|
301
|
+
*/
|
|
302
|
+
export function enforceJupedNick(
|
|
303
|
+
state: ConnectionState,
|
|
304
|
+
nick: string | undefined,
|
|
305
|
+
ctx: Ctx,
|
|
306
|
+
): EffectType[] {
|
|
307
|
+
const services = ctx.services;
|
|
308
|
+
if (services === undefined) return [];
|
|
309
|
+
if (nick === undefined) return [];
|
|
310
|
+
if (!services.isJuped(nick)) return [];
|
|
311
|
+
const sender = state.nick ?? '*';
|
|
312
|
+
const code = Numerics.ERR_ERRONEUSNICKNAME.toString().padStart(3, '0');
|
|
313
|
+
const line: RawLine = {
|
|
314
|
+
text: `:${ctx.serverName} ${code} ${sender} ${nick} :Erroneous nickname`,
|
|
315
|
+
};
|
|
316
|
+
return [Effect.send(state.id, [line])];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ============================================================================
|
|
320
|
+
// Notice helpers
|
|
321
|
+
// ============================================================================
|
|
322
|
+
|
|
323
|
+
/** Builds a `:OperServ!OperServ@services NOTICE <nick> :<text>` line. */
|
|
324
|
+
function notice(state: ConnectionState, text: string): EffectType {
|
|
325
|
+
return Effect.send(state.id, [servicesNotice(OPERSERV_NICK, state, text)]);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Builds a help `:OperServ!OperServ@services NOTICE <nick> :…` line. */
|
|
329
|
+
function helpNotice(state: ConnectionState): EffectType {
|
|
330
|
+
return notice(state, 'Available commands: AKILL, JUPE, RAW');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function unknownNotice(state: ConnectionState): EffectType {
|
|
334
|
+
return notice(state, 'Unknown command. Available: AKILL, JUPE, RAW');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Formats a services NOTICE line. The source hostmask is
|
|
339
|
+
* `<svc>!<svc>@services` regardless of the deployment's serverName so the
|
|
340
|
+
* pseudo-clients are stable across deployments and easy to identify in
|
|
341
|
+
* client UIs.
|
|
342
|
+
*/
|
|
343
|
+
function servicesNotice(svc: string, state: ConnectionState, text: string): RawLine {
|
|
344
|
+
const nick = state.nick ?? '*';
|
|
345
|
+
return { text: `:${svc}!${svc}@${SERVICES_HOST} NOTICE ${nick} :${text}` };
|
|
346
|
+
}
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* `306 RPL_NOWAWAY` is emitted as if the client had sent
|
|
15
15
|
* `AWAY :reason` themselves.
|
|
16
16
|
*
|
|
17
|
-
* Mirrors the
|
|
17
|
+
* Mirrors the read-marker plumbing on the services store: the away
|
|
18
|
+
* persistence is gated on a store being bound AND the connection being
|
|
19
|
+
* identified, the same shape `persistReadMarker` uses.
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
22
|
import { PRE_AWAY_CAP_NAME } from '../caps/capabilities.js';
|
|
@@ -88,6 +88,20 @@ function maySpeakOnModerated(entry: { op: boolean; voice: boolean } | undefined)
|
|
|
88
88
|
return entry.op || entry.voice;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Returns true iff the sender may speak under the ChanServ `+M`
|
|
93
|
+
* (moderated-ident) mode: ops and voiced members always pass, and any
|
|
94
|
+
* identified connection (account set) passes; unidentified non-op/non-voice
|
|
95
|
+
* members are blocked.
|
|
96
|
+
*/
|
|
97
|
+
function maySpeakOnModeratedIdent(
|
|
98
|
+
entry: { op: boolean; voice: boolean } | undefined,
|
|
99
|
+
account: string | undefined,
|
|
100
|
+
): boolean {
|
|
101
|
+
if (entry !== undefined && (entry.op || entry.voice)) return true;
|
|
102
|
+
return account !== undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
91
105
|
/** Command flavor: PRIVMSG surfaces errors; NOTICE is silent. */
|
|
92
106
|
type MessageCommand = 'PRIVMSG' | 'NOTICE';
|
|
93
107
|
|
|
@@ -152,6 +166,34 @@ function buildChannelReducer(command: MessageCommand): Reducer<ChannelState> {
|
|
|
152
166
|
return { state, effects };
|
|
153
167
|
}
|
|
154
168
|
|
|
169
|
+
// ChanServ +M (moderated-ident): ops/voiced/identified pass; everyone
|
|
170
|
+
// else is blocked with 404.
|
|
171
|
+
if (
|
|
172
|
+
state.modes.moderatedIdent &&
|
|
173
|
+
!maySpeakOnModeratedIdent(senderEntry, ctx.connection.account)
|
|
174
|
+
) {
|
|
175
|
+
if (!silent) {
|
|
176
|
+
effects.push(
|
|
177
|
+
Effect.send(ctx.connId, [
|
|
178
|
+
numericErr(ctx, Numerics.ERR_CANNOTSENDTOCHAN, 'Cannot send to channel', state.name),
|
|
179
|
+
]),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return { state, effects };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ChanServ +R (block unidentified): sender must be identified.
|
|
186
|
+
if (state.modes.blockUnidentified && ctx.connection.account === undefined) {
|
|
187
|
+
if (!silent) {
|
|
188
|
+
effects.push(
|
|
189
|
+
Effect.send(ctx.connId, [
|
|
190
|
+
numericErr(ctx, Numerics.ERR_CANNOTSENDTOCHAN, 'Cannot send to channel', state.name),
|
|
191
|
+
]),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return { state, effects };
|
|
195
|
+
}
|
|
196
|
+
|
|
155
197
|
// +b (ban): sender's hostmask must not match any ban mask.
|
|
156
198
|
if (state.banMasks.size > 0) {
|
|
157
199
|
for (const mask of state.banMasks) {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* `TAGMSG <target>` carrying `+draft/read-marker=<msgid>`. The
|
|
15
15
|
* {@link tagmsgChannelReducer} detects the tag, calls
|
|
16
16
|
* {@link persistReadMarker} to advance the per-`(account, channel)`
|
|
17
|
-
* record in the bound {@link
|
|
17
|
+
* record in the bound {@link ServicesStore}, and fans the TAGMSG out
|
|
18
18
|
* (carrying the tag) so the user's other connections learn the new
|
|
19
19
|
* read position.
|
|
20
20
|
* - On SASL identify, the actor calls {@link seedReadMarkers} so a fresh
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { READ_MARKER_CAP_NAME } from '../caps/capabilities.js';
|
|
26
|
-
import type {
|
|
26
|
+
import type { ServicesStore } from '../ports.js';
|
|
27
27
|
import type { ConnectionState } from '../state/connection.js';
|
|
28
28
|
import type { Ctx } from '../types.js';
|
|
29
29
|
|
|
@@ -61,7 +61,7 @@ export function readMarkerMsgid(tags: Readonly<Record<string, string>>): string
|
|
|
61
61
|
* Advances the per-`(account, channel)` last-read marker.
|
|
62
62
|
*
|
|
63
63
|
* Always advances the connection's in-memory {@link ConnectionState.lastReadMarkers}
|
|
64
|
-
* so the current session tracks the read position. When a {@link
|
|
64
|
+
* so the current session tracks the read position. When a {@link ServicesStore}
|
|
65
65
|
* is bound AND the connection is identified (has an `account`), the marker is
|
|
66
66
|
* also persisted so it survives reconnects and is shared across the account's
|
|
67
67
|
* connections.
|
|
@@ -75,10 +75,10 @@ export function persistReadMarker(ctx: Ctx, chanLower: string, msgid: string): v
|
|
|
75
75
|
}
|
|
76
76
|
ctx.connection.lastReadMarkers.set(chanLower, msgid);
|
|
77
77
|
|
|
78
|
-
const
|
|
78
|
+
const services = ctx.services;
|
|
79
79
|
const account = ctx.connection.account;
|
|
80
|
-
if (
|
|
81
|
-
|
|
80
|
+
if (services !== undefined && account !== undefined) {
|
|
81
|
+
services.setLastReadMarker(account, chanLower, msgid);
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -92,10 +92,10 @@ export function persistReadMarker(ctx: Ctx, chanLower: string, msgid: string): v
|
|
|
92
92
|
*/
|
|
93
93
|
export function seedReadMarkers(
|
|
94
94
|
conn: ConnectionState,
|
|
95
|
-
store:
|
|
95
|
+
store: ServicesStore,
|
|
96
96
|
account: string,
|
|
97
97
|
): void {
|
|
98
|
-
const entries = store.
|
|
98
|
+
const entries = store.listReadMarkers(account);
|
|
99
99
|
if (entries.length === 0) return;
|
|
100
100
|
if (conn.lastReadMarkers === undefined) {
|
|
101
101
|
conn.lastReadMarkers = new Map<string, string>();
|
|
@@ -18,11 +18,15 @@ import { formatCreatedAt, resolveServerVersion } from '../config.js';
|
|
|
18
18
|
import { Effect } from '../effects.js';
|
|
19
19
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
20
20
|
import { Numerics } from '../protocol/numerics.js';
|
|
21
|
+
import type { ChanName } from '../state/channel.js';
|
|
21
22
|
import { hostmaskOf } from '../state/connection.js';
|
|
22
23
|
import type { ConnectionState } from '../state/connection.js';
|
|
23
24
|
import type { Ctx, Reducer } from '../types.js';
|
|
25
|
+
import { passBasedAccountAuth } from './account-auth.js';
|
|
24
26
|
import { generateIsupport } from './isupport.js';
|
|
25
27
|
import { buildMotdNumerics } from './motd-lines.js';
|
|
28
|
+
import { enforceRegisteredNick } from './nickserv.js';
|
|
29
|
+
import { checkAkillAdmission, enforceJupedNick } from './operserv.js';
|
|
26
30
|
|
|
27
31
|
/** User mode letters for `004 RPL_MYINFO` (invisible, oper, wallops, server-notices). */
|
|
28
32
|
const USER_MODES_004 = 'iosw';
|
|
@@ -110,6 +114,14 @@ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType
|
|
|
110
114
|
if (state.nick === undefined || state.user === undefined) return [];
|
|
111
115
|
if (state.capNegotiating) return [];
|
|
112
116
|
|
|
117
|
+
// PASS-based account auth runs before the bare server-password gate. On
|
|
118
|
+
// success it identifies the connection (`state.account` set) and emits
|
|
119
|
+
// the account-login numerics (900/903 + downstream effects), satisfying
|
|
120
|
+
// the gate as a side effect. On every other outcome it returns `[]` and
|
|
121
|
+
// the gate below decides. No-op when already identified (SASL), when no
|
|
122
|
+
// `<nick>:<password>` form is present, or when no AccountStore is bound.
|
|
123
|
+
const authEffects = passBasedAccountAuth(state, ctx);
|
|
124
|
+
|
|
113
125
|
const gateFailure = passwordGateFailure(state, ctx);
|
|
114
126
|
if (gateFailure !== null) return gateFailure;
|
|
115
127
|
|
|
@@ -126,7 +138,23 @@ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType
|
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
state.registration = 'registered';
|
|
129
|
-
|
|
141
|
+
// Fire NickServ nick-enforcement when an unidentified client just took a
|
|
142
|
+
// registered nick. Returns [] when no services store is bound or the nick
|
|
143
|
+
// is not registered / the client is identified as the owner. Appended
|
|
144
|
+
// after the welcome Send so the 001 block lands before the warning NOTICE.
|
|
145
|
+
const enforcement = enforceRegisteredNick(state, ctx);
|
|
146
|
+
// OperServ AKILL admission: once the hostmask is computable (post-cloak)
|
|
147
|
+
// check the bound services store for a network-wide ban. Returns [] when
|
|
148
|
+
// no services store is bound, no hostmask is available, or no mask
|
|
149
|
+
// matches. On match the hostmask IS rejected (Disconnect), matching the
|
|
150
|
+
// `$connect` admission contract.
|
|
151
|
+
const akill = checkAkillAdmission(state, ctx);
|
|
152
|
+
return [
|
|
153
|
+
...authEffects,
|
|
154
|
+
Effect.send(ctx.connId, buildWelcomeLines(state, ctx)),
|
|
155
|
+
...enforcement,
|
|
156
|
+
...akill,
|
|
157
|
+
];
|
|
130
158
|
}
|
|
131
159
|
|
|
132
160
|
/**
|
|
@@ -209,6 +237,16 @@ export const nickReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
209
237
|
return { state, effects };
|
|
210
238
|
}
|
|
211
239
|
|
|
240
|
+
// OperServ JUPE: a jupe'd nick is rejected with the same 432 numeric a
|
|
241
|
+
// grammar-invalid nick would surface, for both pre- and post-registration
|
|
242
|
+
// attempts. Returns [] when no services store is bound or the nick is not
|
|
243
|
+
// juped, so the normal flow is undisturbed.
|
|
244
|
+
const jupe = enforceJupedNick(state, nick, ctx);
|
|
245
|
+
if (jupe.length > 0) {
|
|
246
|
+
effects.push(...jupe);
|
|
247
|
+
return { state, effects };
|
|
248
|
+
}
|
|
249
|
+
|
|
212
250
|
if (state.registration === 'registered') {
|
|
213
251
|
const oldNick = state.nick;
|
|
214
252
|
if (oldNick === undefined) {
|
|
@@ -230,7 +268,19 @@ export const nickReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
230
268
|
}
|
|
231
269
|
state.nick = nick;
|
|
232
270
|
effects.push(Effect.changeNick(ctx.connId, oldNick, nick));
|
|
233
|
-
|
|
271
|
+
// The `:oldhostmask NICK newnick` line goes to the originator AND every
|
|
272
|
+
// peer sharing a channel with them (RFC 2812 §3.1.2). Each channel
|
|
273
|
+
// broadcast excludes the originator, who already received the echo via
|
|
274
|
+
// the Send above — matching the QUIT/CHGHOST fanout convention.
|
|
275
|
+
const nickLine: RawLine = { text: `:${source} NICK ${nick}` };
|
|
276
|
+
effects.push(Effect.send(ctx.connId, [nickLine]));
|
|
277
|
+
for (const chan of state.joinedChannels) {
|
|
278
|
+
effects.push(Effect.broadcast(chan as ChanName, [nickLine], ctx.connId));
|
|
279
|
+
}
|
|
280
|
+
// Fire NickServ nick-enforcement when a registered client changes onto a
|
|
281
|
+
// registered nick they are not identified as. Returns [] when no
|
|
282
|
+
// services store is bound or the new nick is not registered.
|
|
283
|
+
effects.push(...enforceRegisteredNick(state, ctx));
|
|
234
284
|
return { state, effects };
|
|
235
285
|
}
|
|
236
286
|
|
|
@@ -290,10 +340,15 @@ export const userReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
290
340
|
/**
|
|
291
341
|
* Reducer for `PASS <password>`.
|
|
292
342
|
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
343
|
+
* Stashes the attempt as `state.passAttempt`. The pure-reducer gate
|
|
344
|
+
* itself lives in {@link passwordGateFailure} (same file), which
|
|
345
|
+
* {@link emitWelcomeIfReady} consults at registration completion:
|
|
346
|
+
* it compares `state.passAttempt` against
|
|
347
|
+
* `ctx.serverConfig.serverPassword` and, on mismatch, emits
|
|
348
|
+
* `464 ERR_PASSWDMISMATCH` plus a `Disconnect("Bad Password")`. A
|
|
349
|
+
* SASL-identified connection (`state.account !== undefined`) short-
|
|
350
|
+
* circuits the gate, so PASS is unnecessary when SASL has already
|
|
351
|
+
* authenticated an account.
|
|
297
352
|
*/
|
|
298
353
|
export const passReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
299
354
|
const effects: EffectType[] = [];
|
|
@@ -32,12 +32,9 @@ import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
|
32
32
|
import type { SaslResult } from '../ports.js';
|
|
33
33
|
import { decodeBase64 } from '../protocol/base64.js';
|
|
34
34
|
import { Numerics } from '../protocol/numerics.js';
|
|
35
|
-
import { hostmaskOf } from '../state/connection.js';
|
|
36
35
|
import type { ConnectionState } from '../state/connection.js';
|
|
37
36
|
import type { Ctx, Reducer } from '../types.js';
|
|
38
|
-
import {
|
|
39
|
-
import { nowAwayLine, replayPersistedAway } from './pre-away.js';
|
|
40
|
-
import { seedReadMarkers } from './read-marker.js';
|
|
37
|
+
import { applyAccountSuccess } from './account-auth.js';
|
|
41
38
|
|
|
42
39
|
/** Per-ircdocs.horse: a single `AUTHENTICATE` payload chunk is at most 400 bytes. */
|
|
43
40
|
const SASL_CHUNK_BYTES = 400;
|
|
@@ -172,18 +169,28 @@ function finalizePlain(
|
|
|
172
169
|
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
173
170
|
}
|
|
174
171
|
|
|
172
|
+
// Prefer the static AccountStore; fall back to the ServicesStore so a
|
|
173
|
+
// nick registered via NickServ can authenticate via SASL PLAIN without a
|
|
174
|
+
// separate static credential table. When neither verifies, fail.
|
|
175
175
|
const store = ctx.accounts;
|
|
176
|
-
|
|
177
|
-
clearSasl(state);
|
|
178
|
-
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const result = store.verify('PLAIN', {
|
|
176
|
+
let result = store?.verify('PLAIN', {
|
|
182
177
|
kind: 'PLAIN',
|
|
183
178
|
username: parsed.username,
|
|
184
179
|
password: parsed.password,
|
|
185
180
|
});
|
|
186
181
|
|
|
182
|
+
if (result === undefined || !result.ok) {
|
|
183
|
+
const servicesResult = ctx.services?.verifyNick(parsed.username, parsed.password);
|
|
184
|
+
if (servicesResult?.ok) {
|
|
185
|
+
result = servicesResult;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (result === undefined) {
|
|
190
|
+
clearSasl(state);
|
|
191
|
+
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
192
|
+
}
|
|
193
|
+
|
|
187
194
|
return applyResult(state, result, ctx);
|
|
188
195
|
}
|
|
189
196
|
|
|
@@ -220,31 +227,7 @@ function applyResult(
|
|
|
220
227
|
clearSasl(state);
|
|
221
228
|
|
|
222
229
|
if (result.ok) {
|
|
223
|
-
state
|
|
224
|
-
// IRCv3 draft/read-marker: restore the user's per-channel last-read
|
|
225
|
-
// markers from the persisted store so a reconnect resumes at their
|
|
226
|
-
// last read position. No-op when no ReadMarkerStore is bound.
|
|
227
|
-
if (ctx.readMarkers !== undefined) {
|
|
228
|
-
seedReadMarkers(state, ctx.readMarkers, result.account);
|
|
229
|
-
}
|
|
230
|
-
// IRCv3 draft/pre-away: replay the user's persisted away reason onto
|
|
231
|
-
// the fresh connection so a reconnect restores the away state, and
|
|
232
|
-
// emit the 306 numeric so the user sees they are still away. No-op
|
|
233
|
-
// when no AwayStore is bound or no reason is stored.
|
|
234
|
-
const replayedAway =
|
|
235
|
-
ctx.away !== undefined ? replayPersistedAway(state, ctx.away, result.account) : undefined;
|
|
236
|
-
const awayEffects: EffectType[] =
|
|
237
|
-
replayedAway !== undefined
|
|
238
|
-
? [Effect.send(ctx.connId, [nowAwayLine(state, ctx.serverName)])]
|
|
239
|
-
: [];
|
|
240
|
-
return {
|
|
241
|
-
state,
|
|
242
|
-
effects: [
|
|
243
|
-
Effect.send(ctx.connId, [loggedIn(ctx, result.account), saslSuccess(ctx)]),
|
|
244
|
-
...awayEffects,
|
|
245
|
-
...emitAccountNotify({ conn: state, account: result.account }),
|
|
246
|
-
],
|
|
247
|
-
};
|
|
230
|
+
return { state, effects: applyAccountSuccess(state, ctx, result.account) };
|
|
248
231
|
}
|
|
249
232
|
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
250
233
|
}
|
|
@@ -274,20 +257,6 @@ function clearSasl(state: ConnectionState): void {
|
|
|
274
257
|
delete state.saslBuffer;
|
|
275
258
|
}
|
|
276
259
|
|
|
277
|
-
function loggedIn(ctx: Ctx, account: string): RawLine {
|
|
278
|
-
const nick = nickOf(ctx);
|
|
279
|
-
const hostmask = hostmaskOf(ctx.connection) ?? nick;
|
|
280
|
-
return L(
|
|
281
|
-
`:${ctx.serverName} ${code(Numerics.RPL_LOGGEDIN)} ${nick} ${hostmask} ${account} :You are now logged in as ${account}`,
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function saslSuccess(ctx: Ctx): RawLine {
|
|
286
|
-
return L(
|
|
287
|
-
`:${ctx.serverName} ${code(Numerics.RPL_SASLSUCCESS)} ${nickOf(ctx)} :SASL authentication successful`,
|
|
288
|
-
);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
260
|
function saslFail(ctx: Ctx): RawLine {
|
|
292
261
|
return L(
|
|
293
262
|
`:${ctx.serverName} ${code(Numerics.ERR_SASLFAIL)} ${nickOf(ctx)} :SASL authentication failed`,
|