serverless-ircd 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/.github/workflows/deploy-aws.yml +156 -32
  2. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  3. package/.github/workflows/deploy-cf.yml +14 -14
  4. package/CHANGELOG.md +305 -0
  5. package/README.md +134 -41
  6. package/apps/aws-stack/README.md +3 -5
  7. package/apps/aws-stack/bin/aws.ts +82 -9
  8. package/apps/aws-stack/cdk.json +0 -3
  9. package/apps/aws-stack/package.json +3 -4
  10. package/apps/aws-stack/src/aws-stack.ts +177 -52
  11. package/apps/aws-stack/src/static-site.ts +323 -0
  12. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  13. package/apps/aws-stack/tests/stack.test.ts +267 -92
  14. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  15. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  16. package/apps/cf-tcp-container/package.json +2 -3
  17. package/apps/cf-tcp-container/src/container-server.ts +12 -9
  18. package/apps/cf-tcp-container/wrangler.toml +1 -10
  19. package/apps/cf-worker/package.json +3 -4
  20. package/apps/cf-worker/wrangler.toml +12 -71
  21. package/apps/local-cli/package.json +1 -1
  22. package/apps/local-cli/src/server.ts +21 -17
  23. package/apps/web/landing/favicon.ico +0 -0
  24. package/apps/web/landing/index.html +1 -0
  25. package/apps/web/package.json +2 -2
  26. package/apps/web/scripts/build.mjs +66 -4
  27. package/apps/web/src/build-env.ts +125 -4
  28. package/apps/web/src/config-schema.ts +20 -6
  29. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  30. package/apps/web/tests/build-env.test.ts +210 -9
  31. package/apps/web/tests/build-smoke.test.ts +2 -2
  32. package/apps/web/tests/config-schema.test.ts +149 -25
  33. package/docs/AWS-Deployment.md +670 -96
  34. package/docs/AWS-TCP-Deployment.md +20 -45
  35. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  36. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  37. package/docs/Release-Process.md +27 -23
  38. package/docs/Services.md +69 -22
  39. package/docs/WebClientGuide.md +35 -26
  40. package/package.json +7 -10
  41. package/packages/aws-adapter/package.json +1 -1
  42. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  43. package/packages/aws-adapter/src/config-loader.ts +19 -2
  44. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  45. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  46. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  47. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +3 -6
  49. package/packages/aws-adapter/src/index.ts +5 -7
  50. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  51. package/packages/aws-adapter/src/serialize.ts +15 -0
  52. package/packages/aws-adapter/src/tables.ts +2 -12
  53. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  54. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  55. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  56. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  57. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  58. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  59. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  60. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  62. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  63. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  64. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  65. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  66. package/packages/cf-adapter/package.json +1 -5
  67. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  68. package/packages/cf-adapter/src/channel-do.ts +13 -3
  69. package/packages/cf-adapter/src/connection-do.ts +266 -109
  70. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  71. package/packages/cf-adapter/src/env.ts +11 -10
  72. package/packages/cf-adapter/src/index.ts +0 -6
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  74. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  75. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +95 -54
  77. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  78. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  79. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  80. package/packages/in-memory-runtime/package.json +1 -1
  81. package/packages/irc-core/package.json +1 -1
  82. package/packages/irc-core/src/account-migration.ts +140 -0
  83. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  84. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  85. package/packages/irc-core/src/commands/nickserv.ts +122 -11
  86. package/packages/irc-core/src/commands/registration.ts +2 -2
  87. package/packages/irc-core/src/commands/sasl.ts +22 -31
  88. package/packages/irc-core/src/config.ts +35 -9
  89. package/packages/irc-core/src/credential-hashing.ts +11 -54
  90. package/packages/irc-core/src/index.ts +1 -0
  91. package/packages/irc-core/src/ports.ts +159 -179
  92. package/packages/irc-core/src/types.ts +38 -10
  93. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  94. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  95. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  96. package/packages/irc-core/tests/commands/nickserv.test.ts +237 -1
  97. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  98. package/packages/irc-core/tests/commands/registration.test.ts +140 -133
  99. package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
  100. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  101. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  102. package/packages/irc-core/tests/message-store.test.ts +5 -0
  103. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  104. package/packages/irc-core/tests/ports.test.ts +71 -0
  105. package/packages/irc-core/tests/services-store.test.ts +204 -0
  106. package/packages/irc-core/vitest.config.ts +6 -1
  107. package/packages/irc-server/package.json +1 -1
  108. package/packages/irc-server/src/actor.ts +0 -14
  109. package/packages/irc-server/tests/actor.test.ts +19 -47
  110. package/packages/irc-test-support/package.json +1 -1
  111. package/pnpm-workspace.yaml +1 -0
  112. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  113. package/scripts/deploy-web-aws.mjs +290 -0
  114. package/scripts/package.json +23 -0
  115. package/scripts/tsconfig.test.json +12 -0
  116. package/scripts/vitest.config.ts +19 -0
  117. package/tools/ci-hardening/package.json +1 -1
  118. package/tools/ci-hardening/src/index.ts +2 -0
  119. package/tools/ci-hardening/src/validate.ts +57 -0
  120. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  121. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  122. package/tools/load-test/package.json +1 -1
  123. package/tools/migrate-accounts-to-services.ts +270 -0
  124. package/tools/package.json +2 -1
  125. package/tools/seed-aws-accounts.ts +35 -10
  126. package/tools/seed-cf-accounts.ts +42 -9
  127. package/tools/tcp-ws-forwarder/package.json +1 -1
  128. package/packages/aws-adapter/src/account-store.ts +0 -121
  129. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  130. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  131. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  132. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  133. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  134. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -0,0 +1,219 @@
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
+
71
+ describe('isConditionalCheckFailed', () => {
72
+ it('returns true for a DynamoDB ConditionalCheckFailedException', () => {
73
+ const err = new Error('The conditional request failed');
74
+ err.name = 'ConditionalCheckFailedException';
75
+ expect(isConditionalCheckFailed(err)).toBe(true);
76
+ });
77
+
78
+ it('returns false for an unrelated error name', () => {
79
+ const err = new Error('boom');
80
+ err.name = 'ProvisionedThroughputExceededException';
81
+ expect(isConditionalCheckFailed(err)).toBe(false);
82
+ });
83
+
84
+ it('returns false for non-object throws', () => {
85
+ expect(isConditionalCheckFailed(null)).toBe(false);
86
+ expect(isConditionalCheckFailed(undefined)).toBe(false);
87
+ expect(isConditionalCheckFailed('string throw')).toBe(false);
88
+ });
89
+ });
90
+
91
+ /**
92
+ * Minimal fake document client. It does NOT implement DynamoDB's
93
+ * expression semantics — it only:
94
+ * - returns the seeded row on GetCommand;
95
+ * - simulates a concurrent writer on UpdateCommand by failing the first
96
+ * `failNextUpdates` writes with ConditionalCheckFailedException (and
97
+ * bumping the row's revision so a re-read sees it), then succeeding.
98
+ * This is enough to exercise `handleDefault`'s retry loop.
99
+ */
100
+ class FakeDynamo {
101
+ readonly items = new Map<string, Record<string, unknown>>();
102
+ failNextUpdates = 0;
103
+ updateCalls = 0;
104
+ getCalls = 0;
105
+
106
+ async send(cmd: unknown): Promise<{ Item?: Record<string, unknown> } | Record<string, never>> {
107
+ if (cmd instanceof GetCommand) {
108
+ this.getCalls += 1;
109
+ const input = (cmd as unknown as { input: { Key: { connectionId: string } } }).input;
110
+ const item = this.items.get(input.Key.connectionId);
111
+ return item === undefined ? {} : { Item: { ...item } };
112
+ }
113
+ if (cmd instanceof UpdateCommand) {
114
+ this.updateCalls += 1;
115
+ const input = (
116
+ cmd as unknown as {
117
+ input: {
118
+ Key: { connectionId: string };
119
+ ExpressionAttributeValues?: Record<string, unknown>;
120
+ };
121
+ }
122
+ ).input;
123
+ const key = input.Key.connectionId;
124
+ if (this.failNextUpdates > 0) {
125
+ this.failNextUpdates -= 1;
126
+ // Simulate a concurrent writer landing first: bump the row's
127
+ // revision so the next read sees the conflict.
128
+ const cur = this.items.get(key);
129
+ if (cur !== undefined) {
130
+ cur.revision = ((cur.revision as number | undefined) ?? 0) + 1;
131
+ }
132
+ const err = new Error('The conditional request failed');
133
+ err.name = 'ConditionalCheckFailedException';
134
+ throw err;
135
+ }
136
+ // Success: bump revision (ADD #rev :revInc) and apply the simple
137
+ // SETs the assertions care about.
138
+ const cur = this.items.get(key);
139
+ if (cur !== undefined) {
140
+ cur.revision = ((cur.revision as number | undefined) ?? 0) + 1;
141
+ const vals = input.ExpressionAttributeValues ?? {};
142
+ if (vals[':capN'] !== undefined) cur.capNegotiating = vals[':capN'];
143
+ }
144
+ return {};
145
+ }
146
+ return {};
147
+ }
148
+ }
149
+
150
+ describe('handleDefault — optimistic-concurrency retry loop', () => {
151
+ it('retries read→run→persist when concurrent writes race, then returns 200', async () => {
152
+ const connId = 'c1';
153
+ const state = createConnection({ id: connId, connectedSince: 1000 });
154
+ // Mid-CAP-negotiation so CAP END is a meaningful, peer-free frame.
155
+ state.capNegotiating = true;
156
+ const dynamo = new FakeDynamo();
157
+ dynamo.items.set(connId, { ...marshalConnection(state, 1000) });
158
+ // Two concurrent invocations write first (conditional-check failures).
159
+ dynamo.failNextUpdates = 2;
160
+
161
+ const result = await handleDefault({
162
+ dynamo: dynamo as unknown as DynamoDBDocumentClient,
163
+ tables: TABLES,
164
+ connectionId: connId,
165
+ body: 'CAP END\r\n',
166
+ serverConfig: SERVER_CONFIG,
167
+ motd: MOTD,
168
+ managementApi: null,
169
+ });
170
+
171
+ expect(result.statusCode).toBe(200);
172
+ // 2 failed writes + 1 committed write, and a re-read per attempt.
173
+ expect(dynamo.updateCalls).toBe(3);
174
+ expect(dynamo.getCalls).toBe(3);
175
+ // Final committed state reflects this invocation's mutation and the
176
+ // revision bumps (2 concurrent + 1 own = 3).
177
+ const finalRow = dynamo.items.get(connId);
178
+ expect(finalRow?.capNegotiating).toBe(false);
179
+ expect(finalRow?.revision).toBe(3);
180
+ });
181
+
182
+ it('commits on the first try when there is no contention', async () => {
183
+ const connId = 'c2';
184
+ const state = createConnection({ id: connId, connectedSince: 1000 });
185
+ state.capNegotiating = true;
186
+ const dynamo = new FakeDynamo();
187
+ dynamo.items.set(connId, { ...marshalConnection(state, 1000) });
188
+
189
+ const result = await handleDefault({
190
+ dynamo: dynamo as unknown as DynamoDBDocumentClient,
191
+ tables: TABLES,
192
+ connectionId: connId,
193
+ body: 'CAP END\r\n',
194
+ serverConfig: SERVER_CONFIG,
195
+ motd: MOTD,
196
+ managementApi: null,
197
+ });
198
+
199
+ expect(result.statusCode).toBe(200);
200
+ expect(dynamo.updateCalls).toBe(1);
201
+ expect(dynamo.getCalls).toBe(1);
202
+ expect(dynamo.items.get(connId)?.capNegotiating).toBe(false);
203
+ });
204
+
205
+ it('returns 410 when the connection row is missing (not retried)', async () => {
206
+ const dynamo = new FakeDynamo();
207
+ const result = await handleDefault({
208
+ dynamo: dynamo as unknown as DynamoDBDocumentClient,
209
+ tables: TABLES,
210
+ connectionId: 'ghost',
211
+ body: 'CAP END\r\n',
212
+ serverConfig: SERVER_CONFIG,
213
+ motd: MOTD,
214
+ managementApi: null,
215
+ });
216
+ expect(result.statusCode).toBe(410);
217
+ expect(dynamo.updateCalls).toBe(0);
218
+ });
219
+ });
@@ -163,6 +163,17 @@ 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
+
166
177
  it('drops a NickServ registration after flush + reload', async () => {
167
178
  const { store, client } = await fresh();
168
179
  store.registerNick('alice', 'pw', 'a@x');
@@ -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,
@@ -262,6 +259,107 @@ describe.skipIf(!available)('handlers', () => {
262
259
  });
263
260
  });
264
261
 
262
+ // -------------------------------------------------------------------------
263
+ // $connect Origin allowlist (CSWSH defence)
264
+ // -------------------------------------------------------------------------
265
+
266
+ describe('$connect Origin allowlist (CSWSH defence)', () => {
267
+ it('denies a disallowed Origin with 403 when WEB_ORIGINS is set', async () => {
268
+ const depsWithOrigins: HandlerDeps = {
269
+ ...requireFx().deps,
270
+ webOrigins: new Set(['https://app.example.com']),
271
+ };
272
+ const res = await dispatch(
273
+ {
274
+ requestContext: { routeKey: '$connect', connectionId: 'c-cswsh' },
275
+ headers: { Origin: 'https://attacker.example.com' },
276
+ },
277
+ depsWithOrigins,
278
+ );
279
+ expect(res.statusCode).toBe(403);
280
+ // The denied upgrade must NOT persist a Connections row — a
281
+ // subsequent $default for the same id hits the missing-row path.
282
+ const probe = new AwsRuntime({
283
+ dynamo: requireFx().client,
284
+ tables: requireFx().tables,
285
+ connId: '__probe__',
286
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
287
+ managementApi: null,
288
+ });
289
+ expect(await probe.getConnectionInfo('c-cswsh')).toBeNull();
290
+ });
291
+
292
+ it('admits an allowed Origin (200 + persisted row)', async () => {
293
+ const depsWithOrigins: HandlerDeps = {
294
+ ...requireFx().deps,
295
+ webOrigins: new Set(['https://app.example.com']),
296
+ };
297
+ const res = await dispatch(
298
+ {
299
+ requestContext: { routeKey: '$connect', connectionId: 'c-ok-origin' },
300
+ headers: { Origin: 'https://app.example.com' },
301
+ },
302
+ depsWithOrigins,
303
+ );
304
+ expect(res.statusCode).toBe(200);
305
+ const probe = new AwsRuntime({
306
+ dynamo: requireFx().client,
307
+ tables: requireFx().tables,
308
+ connId: '__probe__',
309
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
310
+ managementApi: null,
311
+ });
312
+ expect((await probe.getConnectionInfo('c-ok-origin'))?.id).toBe('c-ok-origin');
313
+ });
314
+
315
+ it('matches an `Origin` header served lowercased by APIGW', async () => {
316
+ // API Gateway normalises header keys to lowercase; the handler
317
+ // MUST find `origin` (not just `Origin`) so a CSWSH defence
318
+ // bypass cannot hide behind header-key casing.
319
+ const depsWithOrigins: HandlerDeps = {
320
+ ...requireFx().deps,
321
+ webOrigins: new Set(['https://app.example.com']),
322
+ };
323
+ const res = await dispatch(
324
+ {
325
+ requestContext: { routeKey: '$connect', connectionId: 'c-lower' },
326
+ headers: { origin: 'https://attacker.example.com' },
327
+ },
328
+ depsWithOrigins,
329
+ );
330
+ expect(res.statusCode).toBe(403);
331
+ });
332
+
333
+ it('admits a missing Origin header (non-browser client)', async () => {
334
+ // curl / WeeChat / the tcp-ws-forwarder never send `Origin`;
335
+ // their upgrades must pass through unchanged even when the
336
+ // allowlist is configured.
337
+ const depsWithOrigins: HandlerDeps = {
338
+ ...requireFx().deps,
339
+ webOrigins: new Set(['https://app.example.com']),
340
+ };
341
+ const res = await dispatch(
342
+ { requestContext: { routeKey: '$connect', connectionId: 'c-cli' } },
343
+ depsWithOrigins,
344
+ );
345
+ expect(res.statusCode).toBe(200);
346
+ });
347
+
348
+ it('skips the check entirely when WEB_ORIGINS is unset', async () => {
349
+ // The default — defence is opt-in. Existing AWS deployments
350
+ // without a web frontend must be unchanged on upgrade: any
351
+ // Origin (including an obviously hostile one) proceeds.
352
+ const res = await dispatch(
353
+ {
354
+ requestContext: { routeKey: '$connect', connectionId: 'c-unset' },
355
+ headers: { Origin: 'https://attacker.example.com' },
356
+ },
357
+ requireFx().deps, // no webOrigins
358
+ );
359
+ expect(res.statusCode).toBe(200);
360
+ });
361
+ });
362
+
265
363
  // -------------------------------------------------------------------------
266
364
  // $disconnect
267
365
  // -------------------------------------------------------------------------
@@ -693,7 +791,7 @@ describe.skipIf(!available)('handlers', () => {
693
791
  expect(replay).toBeDefined();
694
792
  });
695
793
 
696
- it('completes SASL PLAIN with 900/903 when an AccountStore is bound', async () => {
794
+ it('completes SASL PLAIN with 900/903 when a ServicesStore is bound', async () => {
697
795
  await dispatch(
698
796
  { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
699
797
  requireFx().deps,
@@ -704,10 +802,18 @@ describe.skipIf(!available)('handlers', () => {
704
802
  if (line.length > 0) sink.push(line);
705
803
  }
706
804
  });
707
- // Swap in a deps with a seeded AccountStore for this dispatch.
708
- const depsWithAccounts: HandlerDeps = {
805
+ // Swap in a deps with a seeded ServicesStore for this dispatch.
806
+ // The store is constructed against the test's DynamoDB client so the
807
+ // post-dispatch flush succeeds; the registration lives in the
808
+ // in-memory cache so SASL PLAIN resolves it synchronously.
809
+ const servicesStore = new DynamoServicesStore({
810
+ docClient: requireFx().client,
811
+ tableName: requireFx().tables.Services ?? 'Services',
812
+ });
813
+ servicesStore.registerNick('aws-sasl', 's3cret', '');
814
+ const depsWithServices: HandlerDeps = {
709
815
  ...requireFx().deps,
710
- accounts: new InMemoryAccountStore([{ username: 'aws-sasl', password: 's3cret' }]),
816
+ services: servicesStore,
711
817
  };
712
818
  const payload = Buffer.from('\0aws-sasl\0s3cret', 'utf8').toString('base64');
713
819
  await dispatch(
@@ -715,14 +821,14 @@ describe.skipIf(!available)('handlers', () => {
715
821
  requestContext: { routeKey: '$default', connectionId: 'c1' },
716
822
  body: `CAP REQ :sasl\r\nAUTHENTICATE PLAIN\r\nAUTHENTICATE ${payload}\r\n`,
717
823
  },
718
- depsWithAccounts,
824
+ depsWithServices,
719
825
  );
720
826
  expect(sink.some((l) => l.includes(' 900 '))).toBe(true);
721
827
  expect(sink.some((l) => l.includes(' 903 '))).toBe(true);
722
828
  expect(sink.some((l) => l.includes(' 904 '))).toBe(false);
723
829
  });
724
830
 
725
- it('emits 904 when no AccountStore is bound (regression guard)', async () => {
831
+ it('emits 904 when no ServicesStore is bound (regression guard)', async () => {
726
832
  await dispatch(
727
833
  { requestContext: { routeKey: '$connect', connectionId: 'c2' } },
728
834
  requireFx().deps,
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Unit tests for the pure helpers exported from `tools/migrate-accounts-to-services.ts`.
3
+ *
4
+ * The helpers are side-effect-free (the CLI entry point is guarded behind
5
+ * `void main()` which only runs when the module is executed directly), so
6
+ * they can be imported and tested without triggering wrangler / DynamoDB
7
+ * IO.
8
+ */
9
+
10
+ import {
11
+ type LegacyAccountRow,
12
+ buildMigrationSql,
13
+ buildServicesPutItem,
14
+ parseSaslAccountsForMigration,
15
+ } from '@serverless-ircd/irc-core';
16
+ import { describe, expect, it } from 'vitest';
17
+
18
+ describe('parseSaslAccountsForMigration', () => {
19
+ it('returns an empty array for undefined', () => {
20
+ expect(parseSaslAccountsForMigration(undefined)).toEqual([]);
21
+ });
22
+
23
+ it('returns an empty array for an empty string', () => {
24
+ expect(parseSaslAccountsForMigration('')).toEqual([]);
25
+ });
26
+
27
+ it('parses a single username:password entry', () => {
28
+ expect(parseSaslAccountsForMigration('alice:s3cret')).toEqual([
29
+ { username: 'alice', password: 's3cret' },
30
+ ]);
31
+ });
32
+
33
+ it('parses multiple newline-delimited entries', () => {
34
+ expect(parseSaslAccountsForMigration('alice:s3cret\nbob:pw')).toEqual([
35
+ { username: 'alice', password: 's3cret' },
36
+ { username: 'bob', password: 'pw' },
37
+ ]);
38
+ });
39
+
40
+ it('skips blank lines and malformed entries', () => {
41
+ expect(
42
+ parseSaslAccountsForMigration('alice:s3cret\n\nnocolon\n:emptyuser\nemptypass:\n \n'),
43
+ ).toEqual([{ username: 'alice', password: 's3cret' }]);
44
+ });
45
+ });
46
+
47
+ describe('buildMigrationSql — D1 migration SQL', () => {
48
+ const legacyRow = (account: string, salt = 'salt', hash = 'hash'): LegacyAccountRow => ({
49
+ account,
50
+ algorithm: 'scrypt',
51
+ salt,
52
+ hash,
53
+ });
54
+
55
+ it('(a) creates a nickserv_accounts row with cert_subjects "[]" for a legacy account', () => {
56
+ const stmts = buildMigrationSql([legacyRow('alice')], undefined);
57
+ // First statement is CREATE TABLE.
58
+ expect(stmts[0]).toContain('CREATE TABLE IF NOT EXISTS nickserv_accounts');
59
+ // Second statement is the INSERT OR IGNORE for alice.
60
+ expect(stmts[1]).toContain('INSERT OR IGNORE INTO nickserv_accounts');
61
+ expect(stmts[1]).toContain("'alice'");
62
+ expect(stmts[1]).toContain("'[]'");
63
+ expect(stmts[1]).toContain("'scrypt'");
64
+ expect(stmts[1]).toContain("'salt'");
65
+ expect(stmts[1]).toContain("'hash'");
66
+ });
67
+
68
+ it('uses INSERT OR IGNORE (never INSERT OR REPLACE) so existing rows are never clobbered', () => {
69
+ const stmts = buildMigrationSql([legacyRow('alice')], undefined);
70
+ expect(stmts[1]).toContain('INSERT OR IGNORE');
71
+ expect(stmts[1]).not.toContain('INSERT OR REPLACE');
72
+ });
73
+
74
+ it('(c) a legacy row never overwrites an existing NickServ registration (INSERT OR IGNORE)', () => {
75
+ // The SQL is INSERT OR IGNORE — if the nick_key already exists in
76
+ // nickserv_accounts, the INSERT is a no-op. The generated SQL encodes
77
+ // this; running it twice produces the same statements.
78
+ const stmts1 = buildMigrationSql([legacyRow('alice')], undefined);
79
+ const stmts2 = buildMigrationSql([legacyRow('alice')], undefined);
80
+ expect(stmts1).toEqual(stmts2);
81
+ });
82
+
83
+ it('carries SASL_ACCOUNTS env-seed entries not already in the legacy table', () => {
84
+ const stmts = buildMigrationSql([legacyRow('alice')], 'bob:bobspw\nalice:alicespw');
85
+ // CREATE TABLE + alice (legacy) + bob (env-seed).
86
+ // alice is in the legacy table so the env-seed alice is NOT duplicated.
87
+ expect(stmts).toHaveLength(3);
88
+ expect(stmts[1]).toContain("'alice'");
89
+ expect(stmts[1]).toContain("'salt'");
90
+ expect(stmts[2]).toContain("'bob'");
91
+ // bob's credential is freshly hashed from the plaintext password.
92
+ expect(stmts[2]).not.toContain("'bobspw'");
93
+ });
94
+
95
+ it('does not re-hash legacy rows (preserves the pre-hashed scrypt credential verbatim)', () => {
96
+ const stmts = buildMigrationSql([legacyRow('alice', 'mysalt', 'myhash')], undefined);
97
+ expect(stmts[1]).toContain("'mysalt'");
98
+ expect(stmts[1]).toContain("'myhash'");
99
+ });
100
+
101
+ it('produces only CREATE TABLE when both legacy rows and env-seed are empty', () => {
102
+ const stmts = buildMigrationSql([], undefined);
103
+ expect(stmts).toHaveLength(1);
104
+ expect(stmts[0]).toContain('CREATE TABLE');
105
+ });
106
+
107
+ it('escapes single quotes in usernames to prevent SQL injection', () => {
108
+ const stmts = buildMigrationSql([legacyRow("O'Brien")], undefined);
109
+ expect(stmts[1]).toContain("'O''Brien'");
110
+ expect(stmts[1]).not.toContain("'O'Brien'");
111
+ });
112
+ });
113
+
114
+ describe('buildServicesPutItem — DynamoDB migration', () => {
115
+ it('targets the NICK:<fold> partition with the nick-type discriminator', () => {
116
+ const input = buildServicesPutItem(
117
+ { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
118
+ 'StagingServices',
119
+ );
120
+ expect(input.TableName).toBe('StagingServices');
121
+ expect(input.Item.pk).toBe('NICK:alice');
122
+ expect(input.Item.sk).toBe('#');
123
+ expect(input.Item.type).toBe('nick');
124
+ });
125
+
126
+ it('preserves the legacy scrypt credential fields on the item', () => {
127
+ const input = buildServicesPutItem(
128
+ { account: 'alice', algorithm: 'scrypt', salt: 'mysalt', hash: 'myhash' },
129
+ 'Services',
130
+ );
131
+ expect(input.Item.algorithm).toBe('scrypt');
132
+ expect(input.Item.salt).toBe('mysalt');
133
+ expect(input.Item.hash).toBe('myhash');
134
+ });
135
+
136
+ it('defaults certSubjects to an empty array', () => {
137
+ const input = buildServicesPutItem(
138
+ { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
139
+ 'Services',
140
+ );
141
+ expect(input.Item.certSubjects).toEqual([]);
142
+ });
143
+
144
+ it('folds the nick case-insensitively into the partition key', () => {
145
+ const input = buildServicesPutItem(
146
+ { account: 'Alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
147
+ 'Services',
148
+ );
149
+ // rfc1459 folding: Alice → alice
150
+ expect(input.Item.pk).toBe('NICK:alice');
151
+ expect(input.Item.nick).toBe('Alice');
152
+ expect(input.Item.account).toBe('Alice');
153
+ });
154
+
155
+ it('stamps the default email / enforce / createdAt fields', () => {
156
+ const input = buildServicesPutItem(
157
+ { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
158
+ 'Services',
159
+ );
160
+ expect(input.Item.email).toBe('');
161
+ expect(input.Item.enforce).toBe('none');
162
+ expect(input.Item.createdAt).toBe(0);
163
+ });
164
+ });