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,226 @@
1
+ /**
2
+ * Tests for the optimistic-concurrency state persist in `$default`
3
+ * (`handleDefault` + `buildPersistUpdate` + `isConditionalCheckFailed`).
4
+ *
5
+ * Background: API Gateway dispatches a client's rapid frames (CAP END,
6
+ * NICK, USER) as CONCURRENT Lambda invocations that each
7
+ * read-modify-write the same Connections row. Without a revision guard,
8
+ * the last writer wins and clobbers the others — the registration
9
+ * deadlock where `CAP END`'s `capNegotiating=false` was overwritten by
10
+ * `NICK`/`USER`'s stale read, so `001 RPL_WELCOME` never fired.
11
+ *
12
+ * The fix: every persist is a conditional `UpdateItem` requiring
13
+ * `revision = :expected` (bumped atomically); a
14
+ * `ConditionalCheckFailedException` triggers a re-read + re-run retry
15
+ * loop in `handleDefault`.
16
+ */
17
+
18
+ import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
19
+ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
20
+ import {
21
+ type ParsedServerConfig,
22
+ StaticMotdProvider,
23
+ createConnection,
24
+ } from '@serverless-ircd/irc-core';
25
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
26
+ import { describe, expect, it } from 'vitest';
27
+ import {
28
+ buildPersistUpdate,
29
+ handleDefault,
30
+ isConditionalCheckFailed,
31
+ } from '../src/handlers/default.js';
32
+ import { marshalConnection } from '../src/serialize.js';
33
+ import type { TablesConfig } from '../src/tables.js';
34
+
35
+ const SERVER_CONFIG: ParsedServerConfig = makeTestServerConfig({
36
+ serverName: 'irc.example.com',
37
+ networkName: 'ExampleNet',
38
+ motdLines: ['Welcome'],
39
+ });
40
+ const MOTD = new StaticMotdProvider(SERVER_CONFIG.motdLines);
41
+ const TABLES: TablesConfig = {
42
+ Connections: 'Connections',
43
+ ChannelMeta: 'ChannelMeta',
44
+ ChannelMembers: 'ChannelMembers',
45
+ Nicks: 'Nicks',
46
+ Services: 'Services',
47
+ };
48
+
49
+ describe('buildPersistUpdate — optimistic-concurrency revision guard', () => {
50
+ it('adds an ADD #rev :revInc clause to the update expression', () => {
51
+ const state = createConnection({ id: 'c1', connectedSince: 1 });
52
+ const expr = buildPersistUpdate(state, 100, 7, []);
53
+ expect(expr.updateExpression).toContain('ADD #rev :revInc');
54
+ });
55
+
56
+ it('gates the write on attribute_not_exists(#rev) OR #rev = :expectedRev', () => {
57
+ const state = createConnection({ id: 'c1', connectedSince: 1 });
58
+ const expr = buildPersistUpdate(state, 100, 7, []);
59
+ expect(expr.conditionExpression).toBe('attribute_not_exists(#rev) OR #rev = :expectedRev');
60
+ });
61
+
62
+ it('binds #rev to the revision column and :expectedRev to the read revision', () => {
63
+ const state = createConnection({ id: 'c1', connectedSince: 1 });
64
+ const expr = buildPersistUpdate(state, 100, 7, []);
65
+ expect(expr.expressionAttributeNames['#rev']).toBe('revision');
66
+ expect(expr.expressionAttributeValues[':expectedRev']).toBe(7);
67
+ expect(expr.expressionAttributeValues[':revInc']).toBe(1);
68
+ });
69
+
70
+ it('persists the inbound frame-window timestamps in the same atomic write', () => {
71
+ const state = createConnection({ id: 'c1', connectedSince: 1 });
72
+ const expr = buildPersistUpdate(state, 100, 7, [90, 95]);
73
+ expect(expr.updateExpression).toContain('frameTimes = :ft');
74
+ expect(expr.expressionAttributeValues[':ft']).toEqual([90, 95]);
75
+ });
76
+ });
77
+
78
+ describe('isConditionalCheckFailed', () => {
79
+ it('returns true for a DynamoDB ConditionalCheckFailedException', () => {
80
+ const err = new Error('The conditional request failed');
81
+ err.name = 'ConditionalCheckFailedException';
82
+ expect(isConditionalCheckFailed(err)).toBe(true);
83
+ });
84
+
85
+ it('returns false for an unrelated error name', () => {
86
+ const err = new Error('boom');
87
+ err.name = 'ProvisionedThroughputExceededException';
88
+ expect(isConditionalCheckFailed(err)).toBe(false);
89
+ });
90
+
91
+ it('returns false for non-object throws', () => {
92
+ expect(isConditionalCheckFailed(null)).toBe(false);
93
+ expect(isConditionalCheckFailed(undefined)).toBe(false);
94
+ expect(isConditionalCheckFailed('string throw')).toBe(false);
95
+ });
96
+ });
97
+
98
+ /**
99
+ * Minimal fake document client. It does NOT implement DynamoDB's
100
+ * expression semantics — it only:
101
+ * - returns the seeded row on GetCommand;
102
+ * - simulates a concurrent writer on UpdateCommand by failing the first
103
+ * `failNextUpdates` writes with ConditionalCheckFailedException (and
104
+ * bumping the row's revision so a re-read sees it), then succeeding.
105
+ * This is enough to exercise `handleDefault`'s retry loop.
106
+ */
107
+ class FakeDynamo {
108
+ readonly items = new Map<string, Record<string, unknown>>();
109
+ failNextUpdates = 0;
110
+ updateCalls = 0;
111
+ getCalls = 0;
112
+
113
+ async send(cmd: unknown): Promise<{ Item?: Record<string, unknown> } | Record<string, never>> {
114
+ if (cmd instanceof GetCommand) {
115
+ this.getCalls += 1;
116
+ const input = (cmd as unknown as { input: { Key: { connectionId: string } } }).input;
117
+ const item = this.items.get(input.Key.connectionId);
118
+ return item === undefined ? {} : { Item: { ...item } };
119
+ }
120
+ if (cmd instanceof UpdateCommand) {
121
+ this.updateCalls += 1;
122
+ const input = (
123
+ cmd as unknown as {
124
+ input: {
125
+ Key: { connectionId: string };
126
+ ExpressionAttributeValues?: Record<string, unknown>;
127
+ };
128
+ }
129
+ ).input;
130
+ const key = input.Key.connectionId;
131
+ if (this.failNextUpdates > 0) {
132
+ this.failNextUpdates -= 1;
133
+ // Simulate a concurrent writer landing first: bump the row's
134
+ // revision so the next read sees the conflict.
135
+ const cur = this.items.get(key);
136
+ if (cur !== undefined) {
137
+ cur.revision = ((cur.revision as number | undefined) ?? 0) + 1;
138
+ }
139
+ const err = new Error('The conditional request failed');
140
+ err.name = 'ConditionalCheckFailedException';
141
+ throw err;
142
+ }
143
+ // Success: bump revision (ADD #rev :revInc) and apply the simple
144
+ // SETs the assertions care about.
145
+ const cur = this.items.get(key);
146
+ if (cur !== undefined) {
147
+ cur.revision = ((cur.revision as number | undefined) ?? 0) + 1;
148
+ const vals = input.ExpressionAttributeValues ?? {};
149
+ if (vals[':capN'] !== undefined) cur.capNegotiating = vals[':capN'];
150
+ }
151
+ return {};
152
+ }
153
+ return {};
154
+ }
155
+ }
156
+
157
+ describe('handleDefault — optimistic-concurrency retry loop', () => {
158
+ it('retries read→run→persist when concurrent writes race, then returns 200', async () => {
159
+ const connId = 'c1';
160
+ const state = createConnection({ id: connId, connectedSince: 1000 });
161
+ // Mid-CAP-negotiation so CAP END is a meaningful, peer-free frame.
162
+ state.capNegotiating = true;
163
+ const dynamo = new FakeDynamo();
164
+ dynamo.items.set(connId, { ...marshalConnection(state, 1000) });
165
+ // Two concurrent invocations write first (conditional-check failures).
166
+ dynamo.failNextUpdates = 2;
167
+
168
+ const result = await handleDefault({
169
+ dynamo: dynamo as unknown as DynamoDBDocumentClient,
170
+ tables: TABLES,
171
+ connectionId: connId,
172
+ body: 'CAP END\r\n',
173
+ serverConfig: SERVER_CONFIG,
174
+ motd: MOTD,
175
+ managementApi: null,
176
+ });
177
+
178
+ expect(result.statusCode).toBe(200);
179
+ // 2 failed writes + 1 committed write, and a re-read per attempt.
180
+ expect(dynamo.updateCalls).toBe(3);
181
+ expect(dynamo.getCalls).toBe(3);
182
+ // Final committed state reflects this invocation's mutation and the
183
+ // revision bumps (2 concurrent + 1 own = 3).
184
+ const finalRow = dynamo.items.get(connId);
185
+ expect(finalRow?.capNegotiating).toBe(false);
186
+ expect(finalRow?.revision).toBe(3);
187
+ });
188
+
189
+ it('commits on the first try when there is no contention', async () => {
190
+ const connId = 'c2';
191
+ const state = createConnection({ id: connId, connectedSince: 1000 });
192
+ state.capNegotiating = true;
193
+ const dynamo = new FakeDynamo();
194
+ dynamo.items.set(connId, { ...marshalConnection(state, 1000) });
195
+
196
+ const result = await handleDefault({
197
+ dynamo: dynamo as unknown as DynamoDBDocumentClient,
198
+ tables: TABLES,
199
+ connectionId: connId,
200
+ body: 'CAP END\r\n',
201
+ serverConfig: SERVER_CONFIG,
202
+ motd: MOTD,
203
+ managementApi: null,
204
+ });
205
+
206
+ expect(result.statusCode).toBe(200);
207
+ expect(dynamo.updateCalls).toBe(1);
208
+ expect(dynamo.getCalls).toBe(1);
209
+ expect(dynamo.items.get(connId)?.capNegotiating).toBe(false);
210
+ });
211
+
212
+ it('returns 410 when the connection row is missing (not retried)', async () => {
213
+ const dynamo = new FakeDynamo();
214
+ const result = await handleDefault({
215
+ dynamo: dynamo as unknown as DynamoDBDocumentClient,
216
+ tables: TABLES,
217
+ connectionId: 'ghost',
218
+ body: 'CAP END\r\n',
219
+ serverConfig: SERVER_CONFIG,
220
+ motd: MOTD,
221
+ managementApi: null,
222
+ });
223
+ expect(result.statusCode).toBe(410);
224
+ expect(dynamo.updateCalls).toBe(0);
225
+ });
226
+ });
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { BatchWriteCommandInput } from '@aws-sdk/lib-dynamodb';
14
- import { FakeClock } from '@serverless-ircd/irc-core';
14
+ import { FakeClock, certIdentityFromSubject } from '@serverless-ircd/irc-core';
15
15
  import { describe, expect, it } from 'vitest';
16
16
  import { DynamoServicesStore, loadDynamoServicesStore } from '../src/dynamo-services-store';
17
17
 
@@ -163,6 +163,43 @@ describe('DynamoServicesStore — round-trip via fake DDB', () => {
163
163
  expect(reloaded.getNickEnforce('alice')).toBe('kill');
164
164
  });
165
165
 
166
+ it('persists SET PASSWORD and round-trips the new credential', async () => {
167
+ const { store, client } = await fresh();
168
+ store.registerNick('alice', 'hunter2', 'a@x');
169
+ store.setNickPassword('alice', 'newpassword');
170
+ await store.flush();
171
+
172
+ const reloaded = await reload(client);
173
+ expect(reloaded.verifyNick('alice', 'newpassword')).toEqual({ ok: true, account: 'alice' });
174
+ expect(reloaded.verifyNick('alice', 'hunter2').ok).toBe(false);
175
+ });
176
+
177
+ it('persists the identify-failure throttle and round-trips the freeze', async () => {
178
+ const clock = new FakeClock(NOW);
179
+ const { client } = makeFakeClient();
180
+ const first = await loadDynamoServicesStore(asClient(client), 'Services', clock);
181
+ if (first === undefined) throw new Error('store not loaded');
182
+ first.registerNick('alice', 'hunter2', 'a@x');
183
+ clock.advance(10);
184
+ first.recordIdentifyFailure('alice');
185
+ clock.advance(10);
186
+ first.recordIdentifyFailure('alice');
187
+ await first.flush();
188
+
189
+ // Fresh connection / deploy: the reload still sees the freeze.
190
+ const reloaded = await reload(client);
191
+ expect(reloaded.isNickIdentifyFrozen('alice', { maxFailures: 2, windowMs: 300_000 })).toBe(
192
+ true,
193
+ );
194
+ expect(reloaded.clearIdentifyFailures('alice')).toBe(2);
195
+
196
+ // The cleared state round-trips too.
197
+ await reloaded.flush();
198
+ const after = await reload(client);
199
+ expect(after.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
200
+ expect(after.clearIdentifyFailures('alice')).toBe(0);
201
+ });
202
+
166
203
  it('drops a NickServ registration after flush + reload', async () => {
167
204
  const { store, client } = await fresh();
168
205
  store.registerNick('alice', 'pw', 'a@x');
@@ -173,6 +210,54 @@ describe('DynamoServicesStore — round-trip via fake DDB', () => {
173
210
  expect((await reload(client)).isRegisteredNick('alice')).toBe(false);
174
211
  });
175
212
 
213
+ it('round-trips a bound cert fingerprint and verifies EXTERNAL after reload', async () => {
214
+ // A cert binding attached before a deploy must still authenticate a
215
+ // SASL EXTERNAL client after the store rehydrates from DynamoDB —
216
+ // the production path for certificate-backed login.
217
+ const { store, client } = await fresh();
218
+ store.registerNick('alice', 'hunter2', 'a@x');
219
+ store.addCertFingerprint('alice', 'AA112233445566778899AABBCCDDEEFF');
220
+ await store.flush();
221
+
222
+ const reloaded = await reload(client);
223
+ // The fingerprint is normalised (lowercase, colon-free) on the round-trip.
224
+ expect(reloaded.getNick('alice')?.certSubjects).toEqual([
225
+ 'fp:aa112233445566778899aabbccddeeff',
226
+ ]);
227
+ expect(
228
+ reloaded.verifyCertFP(
229
+ certIdentityFromSubject('CN=alice', 'aa112233445566778899aabbccddeeff'),
230
+ ),
231
+ ).toEqual({ ok: true, account: 'alice' });
232
+ });
233
+
234
+ it('round-trips a bound subject DN and verifies EXTERNAL via the canonical DN arm', async () => {
235
+ const { store, client } = await fresh();
236
+ store.registerNick('alice', 'hunter2', 'a@x');
237
+ store.addCertFP('alice', 'CN=alice,O=Example Inc');
238
+ await store.flush();
239
+
240
+ const reloaded = await reload(client);
241
+ // Platform-surfaced DN in a different RDN order still matches (canonical compare).
242
+ expect(reloaded.verifyCertFP(certIdentityFromSubject('O=Example Inc,CN=alice'))).toEqual({
243
+ ok: true,
244
+ account: 'alice',
245
+ });
246
+ });
247
+
248
+ it('keeps EXTERNAL verify failing for an unbound cert after reload', async () => {
249
+ const { store, client } = await fresh();
250
+ store.registerNick('alice', 'hunter2', 'a@x');
251
+ store.addCertFingerprint('alice', 'aa112233445566778899aabbccddeeff');
252
+ await store.flush();
253
+
254
+ const reloaded = await reload(client);
255
+ expect(
256
+ reloaded.verifyCertFP(certIdentityFromSubject('CN=alice', 'ffffffffffffffffffffffffffffffff'))
257
+ .ok,
258
+ ).toBe(false);
259
+ });
260
+
176
261
  it('persists channel + access + levels + topic, then drops the whole subtree', async () => {
177
262
  const { store, client } = await fresh();
178
263
  store.registerChannel('#chan', 'alice');
@@ -313,6 +398,54 @@ describe('DynamoServicesStore — round-trip via fake DDB', () => {
313
398
  });
314
399
 
315
400
  describe('DynamoServicesStore — snapshot coercion tolerance', () => {
401
+ it('defaults the identify-throttle fields on a legacy nick item (attrs absent)', async () => {
402
+ // A nick item written before the identify throttle existed carries no
403
+ // identifyFailures / pendingIdentifyFailureNotice attributes.
404
+ const { client, items } = makeFakeClient();
405
+ items.set('NICK:alice\x00#', {
406
+ pk: 'NICK:alice',
407
+ sk: '#',
408
+ type: 'nick',
409
+ nick: 'alice',
410
+ account: 'alice',
411
+ email: 'a@x',
412
+ createdAt: NOW,
413
+ enforce: 'none',
414
+ // Mixed-types array: exercises the string-filter callback on load.
415
+ certSubjects: ['CN=alice', 7],
416
+ algorithm: 'scrypt',
417
+ salt: 'c2FsdA==',
418
+ hash: 'aGFzaA==',
419
+ });
420
+ const store = await loadDynamoServicesStore(asClient(client), 'Services', new FakeClock(NOW));
421
+ if (store === undefined) throw new Error('store not loaded');
422
+ expect(store.isRegisteredNick('alice')).toBe(true);
423
+ expect(store.getNick('alice')?.certSubjects).toEqual(['CN=alice']);
424
+ expect(store.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
425
+ expect(store.clearIdentifyFailures('alice')).toBe(0);
426
+ // Ill-typed throttle attributes collapse to the same neutral defaults.
427
+ items.set('NICK:bob\x00#', {
428
+ pk: 'NICK:bob',
429
+ sk: '#',
430
+ type: 'nick',
431
+ nick: 'bob',
432
+ account: 'bob',
433
+ email: 'b@x',
434
+ createdAt: NOW,
435
+ enforce: 'none',
436
+ certSubjects: [],
437
+ identifyFailures: 'soon',
438
+ pendingIdentifyFailureNotice: 'many',
439
+ algorithm: 'scrypt',
440
+ salt: 'c2FsdA==',
441
+ hash: 'aGFzaA==',
442
+ });
443
+ const second = await loadDynamoServicesStore(asClient(client), 'Services', new FakeClock(NOW));
444
+ if (second === undefined) throw new Error('store not loaded');
445
+ expect(second.isNickIdentifyFrozen('bob', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
446
+ expect(second.clearIdentifyFailures('bob')).toBe(0);
447
+ });
448
+
316
449
  it('skips items with unknown or ill-typed fields', async () => {
317
450
  // Hand-craft malformed items directly in the fake table.
318
451
  const { client, items } = makeFakeClient();
@@ -8,16 +8,13 @@
8
8
 
9
9
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
10
10
  import { GetCommand } from '@aws-sdk/lib-dynamodb';
11
- import {
12
- InMemoryAccountStore,
13
- type ParsedServerConfig,
14
- StaticMotdProvider,
15
- } from '@serverless-ircd/irc-core';
11
+ import { type ParsedServerConfig, StaticMotdProvider } from '@serverless-ircd/irc-core';
16
12
  import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
17
13
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
18
14
  import { AwsRuntime } from '../src/aws-runtime.js';
19
15
  import type { PostToConnection } from '../src/aws-runtime.js';
20
16
  import { TABLE_DEFS } from '../src/cdk-table-defs.js';
17
+ import { DynamoServicesStore } from '../src/dynamo-services-store.js';
21
18
  import { createDynamoDocumentClient } from '../src/dynamo.js';
22
19
  import {
23
20
  type HandlerDeps,
@@ -65,7 +62,7 @@ async function makeFixture(): Promise<Fixture> {
65
62
  const pkName = props.partitionKey?.name;
66
63
  const skName = props.sortKey?.name;
67
64
  if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
68
- const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
65
+ const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' | 'N' }> = [
69
66
  { AttributeName: pkName, AttributeType: 'S' },
70
67
  ];
71
68
  const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
@@ -75,11 +72,33 @@ async function makeFixture(): Promise<Fixture> {
75
72
  attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
76
73
  keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
77
74
  }
75
+ // Mirror the CDK-declared secondary indexes (the Connections per-IP
76
+ // admission GSI) so the fixture matches the deployed schema.
77
+ const globalSecondaryIndexes = (props.globalSecondaryIndexes ?? []).map((g) => {
78
+ const gpk = g.partitionKey;
79
+ if (gpk === undefined) throw new Error(`index ${g.indexName} missing partitionKey`);
80
+ const gsiKeySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
81
+ { AttributeName: gpk.name, KeyType: 'HASH' },
82
+ ];
83
+ attributeDefinitions.push({ AttributeName: gpk.name, AttributeType: 'S' });
84
+ if (g.sortKey !== undefined) {
85
+ gsiKeySchema.push({ AttributeName: g.sortKey.name, KeyType: 'RANGE' });
86
+ attributeDefinitions.push({ AttributeName: g.sortKey.name, AttributeType: 'N' });
87
+ }
88
+ return {
89
+ IndexName: g.indexName,
90
+ KeySchema: gsiKeySchema,
91
+ Projection: { ProjectionType: 'ALL' as const },
92
+ };
93
+ });
78
94
  await client.send(
79
95
  new CreateTableCommand({
80
96
  TableName: `${prefix}${logical}`,
81
97
  AttributeDefinitions: attributeDefinitions,
82
98
  KeySchema: keySchema,
99
+ ...(globalSecondaryIndexes.length > 0
100
+ ? { GlobalSecondaryIndexes: globalSecondaryIndexes }
101
+ : {}),
83
102
  BillingMode: 'PAY_PER_REQUEST',
84
103
  }),
85
104
  );
@@ -260,6 +279,141 @@ describe.skipIf(!available)('handlers', () => {
260
279
  });
261
280
  expect((await probe.getConnectionInfo('b1'))?.id).toBe('b1');
262
281
  });
282
+
283
+ it('rejects the per-IP cap via identity.sourceIp and the sourceIp GSI', async () => {
284
+ // End-to-end per-IP admission: dispatch feeds
285
+ // `event.requestContext.identity.sourceIp` into handleConnect,
286
+ // which counts the IP's live rows on the `sourceIp` GSI and fires
287
+ // the (previously dead) maxConnectionsPerIp branch.
288
+ const capped: HandlerDeps = {
289
+ ...requireFx().deps,
290
+ serverConfig: { ...SERVER_CONFIG, maxClients: 50, maxConnectionsPerIp: 2 },
291
+ };
292
+ const fromIp = (id: string, ip: string) =>
293
+ dispatch(
294
+ {
295
+ requestContext: { routeKey: '$connect', connectionId: id, identity: { sourceIp: ip } },
296
+ },
297
+ capped,
298
+ );
299
+ expect((await fromIp('ip-a1', '203.0.113.30')).statusCode).toBe(200);
300
+ expect((await fromIp('ip-a2', '203.0.113.30')).statusCode).toBe(200);
301
+ const third = await fromIp('ip-a3', '203.0.113.30');
302
+ expect(third.statusCode).toBe(429);
303
+ expect(third.body).toBe('too many connections from this IP');
304
+ // A different IP is unaffected — the budget is keyed per IP.
305
+ expect((await fromIp('ip-b1', '198.51.100.30')).statusCode).toBe(200);
306
+ // The rejected connection must NOT be recorded.
307
+ const probe = new AwsRuntime({
308
+ dynamo: requireFx().client,
309
+ tables: requireFx().tables,
310
+ connId: '__probe__',
311
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
312
+ managementApi: null,
313
+ });
314
+ expect(await probe.getConnectionInfo('ip-a3')).toBeNull();
315
+ });
316
+ });
317
+
318
+ // -------------------------------------------------------------------------
319
+ // $connect Origin allowlist (CSWSH defence)
320
+ // -------------------------------------------------------------------------
321
+
322
+ describe('$connect Origin allowlist (CSWSH defence)', () => {
323
+ it('denies a disallowed Origin with 403 when WEB_ORIGINS is set', async () => {
324
+ const depsWithOrigins: HandlerDeps = {
325
+ ...requireFx().deps,
326
+ webOrigins: new Set(['https://app.example.com']),
327
+ };
328
+ const res = await dispatch(
329
+ {
330
+ requestContext: { routeKey: '$connect', connectionId: 'c-cswsh' },
331
+ headers: { Origin: 'https://attacker.example.com' },
332
+ },
333
+ depsWithOrigins,
334
+ );
335
+ expect(res.statusCode).toBe(403);
336
+ // The denied upgrade must NOT persist a Connections row — a
337
+ // subsequent $default for the same id hits the missing-row path.
338
+ const probe = new AwsRuntime({
339
+ dynamo: requireFx().client,
340
+ tables: requireFx().tables,
341
+ connId: '__probe__',
342
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
343
+ managementApi: null,
344
+ });
345
+ expect(await probe.getConnectionInfo('c-cswsh')).toBeNull();
346
+ });
347
+
348
+ it('admits an allowed Origin (200 + persisted row)', async () => {
349
+ const depsWithOrigins: HandlerDeps = {
350
+ ...requireFx().deps,
351
+ webOrigins: new Set(['https://app.example.com']),
352
+ };
353
+ const res = await dispatch(
354
+ {
355
+ requestContext: { routeKey: '$connect', connectionId: 'c-ok-origin' },
356
+ headers: { Origin: 'https://app.example.com' },
357
+ },
358
+ depsWithOrigins,
359
+ );
360
+ expect(res.statusCode).toBe(200);
361
+ const probe = new AwsRuntime({
362
+ dynamo: requireFx().client,
363
+ tables: requireFx().tables,
364
+ connId: '__probe__',
365
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
366
+ managementApi: null,
367
+ });
368
+ expect((await probe.getConnectionInfo('c-ok-origin'))?.id).toBe('c-ok-origin');
369
+ });
370
+
371
+ it('matches an `Origin` header served lowercased by APIGW', async () => {
372
+ // API Gateway normalises header keys to lowercase; the handler
373
+ // MUST find `origin` (not just `Origin`) so a CSWSH defence
374
+ // bypass cannot hide behind header-key casing.
375
+ const depsWithOrigins: HandlerDeps = {
376
+ ...requireFx().deps,
377
+ webOrigins: new Set(['https://app.example.com']),
378
+ };
379
+ const res = await dispatch(
380
+ {
381
+ requestContext: { routeKey: '$connect', connectionId: 'c-lower' },
382
+ headers: { origin: 'https://attacker.example.com' },
383
+ },
384
+ depsWithOrigins,
385
+ );
386
+ expect(res.statusCode).toBe(403);
387
+ });
388
+
389
+ it('admits a missing Origin header (non-browser client)', async () => {
390
+ // curl / WeeChat / the tcp-ws-forwarder never send `Origin`;
391
+ // their upgrades must pass through unchanged even when the
392
+ // allowlist is configured.
393
+ const depsWithOrigins: HandlerDeps = {
394
+ ...requireFx().deps,
395
+ webOrigins: new Set(['https://app.example.com']),
396
+ };
397
+ const res = await dispatch(
398
+ { requestContext: { routeKey: '$connect', connectionId: 'c-cli' } },
399
+ depsWithOrigins,
400
+ );
401
+ expect(res.statusCode).toBe(200);
402
+ });
403
+
404
+ it('skips the check entirely when WEB_ORIGINS is unset', async () => {
405
+ // The default — defence is opt-in. Existing AWS deployments
406
+ // without a web frontend must be unchanged on upgrade: any
407
+ // Origin (including an obviously hostile one) proceeds.
408
+ const res = await dispatch(
409
+ {
410
+ requestContext: { routeKey: '$connect', connectionId: 'c-unset' },
411
+ headers: { Origin: 'https://attacker.example.com' },
412
+ },
413
+ requireFx().deps, // no webOrigins
414
+ );
415
+ expect(res.statusCode).toBe(200);
416
+ });
263
417
  });
264
418
 
265
419
  // -------------------------------------------------------------------------
@@ -693,7 +847,7 @@ describe.skipIf(!available)('handlers', () => {
693
847
  expect(replay).toBeDefined();
694
848
  });
695
849
 
696
- it('completes SASL PLAIN with 900/903 when an AccountStore is bound', async () => {
850
+ it('completes SASL PLAIN with 900/903 when a ServicesStore is bound', async () => {
697
851
  await dispatch(
698
852
  { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
699
853
  requireFx().deps,
@@ -704,10 +858,18 @@ describe.skipIf(!available)('handlers', () => {
704
858
  if (line.length > 0) sink.push(line);
705
859
  }
706
860
  });
707
- // Swap in a deps with a seeded AccountStore for this dispatch.
708
- const depsWithAccounts: HandlerDeps = {
861
+ // Swap in a deps with a seeded ServicesStore for this dispatch.
862
+ // The store is constructed against the test's DynamoDB client so the
863
+ // post-dispatch flush succeeds; the registration lives in the
864
+ // in-memory cache so SASL PLAIN resolves it synchronously.
865
+ const servicesStore = new DynamoServicesStore({
866
+ docClient: requireFx().client,
867
+ tableName: requireFx().tables.Services ?? 'Services',
868
+ });
869
+ servicesStore.registerNick('aws-sasl', 's3cret', '');
870
+ const depsWithServices: HandlerDeps = {
709
871
  ...requireFx().deps,
710
- accounts: new InMemoryAccountStore([{ username: 'aws-sasl', password: 's3cret' }]),
872
+ services: servicesStore,
711
873
  };
712
874
  const payload = Buffer.from('\0aws-sasl\0s3cret', 'utf8').toString('base64');
713
875
  await dispatch(
@@ -715,14 +877,14 @@ describe.skipIf(!available)('handlers', () => {
715
877
  requestContext: { routeKey: '$default', connectionId: 'c1' },
716
878
  body: `CAP REQ :sasl\r\nAUTHENTICATE PLAIN\r\nAUTHENTICATE ${payload}\r\n`,
717
879
  },
718
- depsWithAccounts,
880
+ depsWithServices,
719
881
  );
720
882
  expect(sink.some((l) => l.includes(' 900 '))).toBe(true);
721
883
  expect(sink.some((l) => l.includes(' 903 '))).toBe(true);
722
884
  expect(sink.some((l) => l.includes(' 904 '))).toBe(false);
723
885
  });
724
886
 
725
- it('emits 904 when no AccountStore is bound (regression guard)', async () => {
887
+ it('emits 904 when no ServicesStore is bound (regression guard)', async () => {
726
888
  await dispatch(
727
889
  { requestContext: { routeKey: '$connect', connectionId: 'c2' } },
728
890
  requireFx().deps,