serverless-ircd 0.2.0 → 0.3.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/CHANGELOG.md +84 -0
  2. package/README.md +28 -19
  3. package/apps/cf-worker/package.json +1 -1
  4. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  5. package/apps/cf-worker/src/worker.ts +8 -2
  6. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  7. package/apps/cf-worker/wrangler.test.toml +5 -1
  8. package/apps/cf-worker/wrangler.toml +41 -17
  9. package/apps/local-cli/package.json +1 -1
  10. package/apps/local-cli/src/config-loader.ts +1 -0
  11. package/apps/local-cli/src/main.ts +1 -1
  12. package/apps/local-cli/src/server.ts +28 -2
  13. package/apps/local-cli/tests/e2e.test.ts +52 -0
  14. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  15. package/docs/ADR-002-location-of-authority.md +82 -0
  16. package/docs/ADR-003-durable-object-sharding.md +93 -0
  17. package/docs/ADR-004-dynamodb-schema.md +96 -0
  18. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  19. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  20. package/docs/ADR-007-deterministic-ports.md +82 -0
  21. package/docs/ADR-008-monorepo-tooling.md +60 -0
  22. package/docs/AWS-Adapter-Architecture.md +6 -4
  23. package/docs/AWS-Deployment.md +86 -7
  24. package/docs/Cloudflare-Deployment-Guide.md +5 -5
  25. package/docs/Home.md +11 -1
  26. package/docs/Release-Process.md +9 -6
  27. package/package.json +14 -15
  28. package/packages/aws-adapter/package.json +1 -1
  29. package/packages/aws-adapter/src/account-store.ts +121 -0
  30. package/packages/aws-adapter/src/aws-runtime.ts +118 -30
  31. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  32. package/packages/aws-adapter/src/config-loader.ts +30 -0
  33. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  34. package/packages/aws-adapter/src/handlers/default.ts +8 -0
  35. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  36. package/packages/aws-adapter/src/handlers/index.ts +51 -6
  37. package/packages/aws-adapter/src/index.ts +7 -0
  38. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  39. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  40. package/packages/aws-adapter/tests/config-loader.test.ts +55 -0
  41. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  42. package/packages/aws-adapter/tests/handlers.test.ts +46 -0
  43. package/packages/cf-adapter/package.json +1 -1
  44. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  45. package/packages/cf-adapter/src/channel-do.ts +40 -1
  46. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  47. package/packages/cf-adapter/src/config-loader.ts +29 -0
  48. package/packages/cf-adapter/src/connection-do.ts +85 -0
  49. package/packages/cf-adapter/src/env.ts +27 -1
  50. package/packages/cf-adapter/src/index.ts +2 -1
  51. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  52. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  53. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  54. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  55. package/packages/cf-adapter/wrangler.test.toml +10 -1
  56. package/packages/in-memory-runtime/package.json +1 -1
  57. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  58. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  59. package/packages/irc-core/package.json +3 -2
  60. package/packages/irc-core/src/case-fold.ts +64 -0
  61. package/packages/irc-core/src/commands/index.ts +2 -0
  62. package/packages/irc-core/src/commands/invite.ts +6 -9
  63. package/packages/irc-core/src/commands/isupport.ts +1 -1
  64. package/packages/irc-core/src/commands/join.ts +4 -3
  65. package/packages/irc-core/src/commands/kick.ts +4 -11
  66. package/packages/irc-core/src/commands/list.ts +5 -4
  67. package/packages/irc-core/src/commands/mode.ts +5 -9
  68. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  69. package/packages/irc-core/src/commands/motd.ts +6 -110
  70. package/packages/irc-core/src/commands/oper.ts +151 -0
  71. package/packages/irc-core/src/commands/registration.ts +8 -7
  72. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  73. package/packages/irc-core/src/config.ts +26 -3
  74. package/packages/irc-core/src/index.ts +2 -0
  75. package/packages/irc-core/src/ports.ts +68 -4
  76. package/packages/irc-core/src/types.ts +22 -0
  77. package/packages/irc-core/tests/account-store.test.ts +88 -0
  78. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  79. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  80. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  81. package/packages/irc-core/tests/commands/registration.test.ts +106 -14
  82. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  83. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  84. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  85. package/packages/irc-core/tests/config.test.ts +75 -0
  86. package/packages/irc-server/package.json +1 -1
  87. package/packages/irc-server/src/actor.ts +24 -0
  88. package/packages/irc-server/src/routing.ts +9 -0
  89. package/packages/irc-server/tests/actor.test.ts +220 -1
  90. package/packages/irc-server/tests/routing.test.ts +3 -0
  91. package/packages/irc-test-support/package.json +1 -1
  92. package/packages/irc-test-support/src/in-memory-harness.ts +5 -4
  93. package/pnpm-workspace.yaml +1 -0
  94. package/tools/seed-aws-accounts.ts +80 -0
  95. package/AGENTS.md +0 -5
  96. package/MOTD.txt +0 -3
  97. package/PLAN-FIXES.md +0 -358
  98. package/PLAN.md +0 -420
  99. package/dashboards/cloudwatch-irc.json +0 -139
  100. package/progress.md +0 -107
  101. package/tickets.md +0 -2485
  102. package/webircgateway/LICENSE +0 -201
  103. package/webircgateway/Makefile +0 -44
  104. package/webircgateway/README.md +0 -134
  105. package/webircgateway/config.conf.example +0 -135
  106. package/webircgateway/go.mod +0 -16
  107. package/webircgateway/go.sum +0 -89
  108. package/webircgateway/main.go +0 -118
  109. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  110. package/webircgateway/pkg/identd/identd.go +0 -86
  111. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  112. package/webircgateway/pkg/irc/isupport.go +0 -56
  113. package/webircgateway/pkg/irc/message.go +0 -217
  114. package/webircgateway/pkg/irc/state.go +0 -79
  115. package/webircgateway/pkg/proxy/proxy.go +0 -129
  116. package/webircgateway/pkg/proxy/server.go +0 -237
  117. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  118. package/webircgateway/pkg/webircgateway/client.go +0 -741
  119. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  120. package/webircgateway/pkg/webircgateway/config.go +0 -385
  121. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  122. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  123. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  124. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  125. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  126. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  127. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  128. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  129. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  130. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  131. package/webircgateway/plugins/example/plugin.go +0 -11
  132. package/webircgateway/plugins/stats/plugin.go +0 -52
  133. package/webircgateway/staticcheck.conf +0 -1
  134. package/webircgateway/webircgateway.svg +0 -3
@@ -2,13 +2,15 @@
2
2
  * `$disconnect` handler — Lambda entry for `event.requestContext.routeKey === '$disconnect'`.
3
3
  *
4
4
  * Tears down the connection via {@link cleanupConnection} — deletes the
5
- * Connections row, every ChannelMembers row owned by it, and the nick
6
- * reservation if any. Idempotent: every step tolerates a missing row.
5
+ * Connections row, every ChannelMembers row owned by it, the nick
6
+ * reservation if any, and broadcasts a `QUIT` line to every peer sharing
7
+ * a channel with the disconnecting connection. Idempotent: every step
8
+ * tolerates a missing row.
7
9
  */
8
10
 
9
11
  import type { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
10
12
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
11
- import { cleanupConnection } from '../aws-runtime.js';
13
+ import { type PostToConnection, cleanupConnection } from '../aws-runtime.js';
12
14
  import type { TablesConfig } from '../tables.js';
13
15
 
14
16
  /** Parameters accepted by {@link handleDisconnect}. */
@@ -16,14 +18,31 @@ export interface DisconnectParams {
16
18
  dynamo: DynamoDBDocumentClient;
17
19
  tables: TablesConfig;
18
20
  connectionId: string;
19
- /** Reserved for future "broadcast QUIT to peers" work; unused today. */
20
- managementApi?: ApiGatewayManagementApi | null;
21
+ /**
22
+ * Backend used to push the `QUIT` fanout to peers. `null` skips the
23
+ * fanout (the socket is already gone, but peers still need the QUIT
24
+ * line so their rosters prune the ghost member).
25
+ */
26
+ managementApi?: ApiGatewayManagementApi | PostToConnection | null;
27
+ /**
28
+ * QUIT reason advertised to peers on fanout. Defaults to
29
+ * `'Client Quit'` (the same default the QUIT reducer uses when a
30
+ * client sends `QUIT` with no reason).
31
+ */
32
+ quitMessage?: string;
21
33
  }
22
34
 
23
35
  /**
24
- * Cleans up the connection state. Safe to call after APIGW has already
25
- * torn the WebSocket down the management API is best-effort only.
36
+ * Cleans up the connection state and broadcasts QUIT to shared-channel
37
+ * peers. Safe to call after APIGW has already torn the WebSocket down —
38
+ * the management API is best-effort only.
26
39
  */
27
40
  export async function handleDisconnect(params: DisconnectParams): Promise<void> {
28
- await cleanupConnection(params.dynamo, params.tables, params.connectionId, null);
41
+ await cleanupConnection(
42
+ params.dynamo,
43
+ params.tables,
44
+ params.connectionId,
45
+ params.managementApi ?? null,
46
+ params.quitMessage,
47
+ );
29
48
  }
@@ -14,12 +14,14 @@
14
14
  import { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
15
15
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
16
16
  import {
17
+ type AccountStore,
17
18
  EmptyMotdProvider,
18
19
  type MessageStore,
19
20
  type MotdProvider,
20
21
  type ParsedServerConfig,
21
22
  StaticMotdProvider,
22
23
  } from '@serverless-ircd/irc-core';
24
+ import { resolveAccountStore } from '../account-store.js';
23
25
  import { type LambdaConfigEnv, loadServerConfigFromLambdaEnv } from '../config-loader.js';
24
26
  import { createDynamoDocumentClient } from '../dynamo.js';
25
27
  import { bindMessageStore } from '../message-store.js';
@@ -72,6 +74,13 @@ export interface HandlerDeps {
72
74
  * DynamoDB-backed variant is a documented follow-up.
73
75
  */
74
76
  messages: MessageStore;
77
+ /**
78
+ * SASL account verification source bound to every actor. Constructed
79
+ * once per cold start (memoised via {@link cachedDeps}) so it survives
80
+ * across warm Lambda invocations. `undefined` when no accounts are
81
+ * configured (the default) so `ctx.accounts` stays unset.
82
+ */
83
+ accounts?: AccountStore;
75
84
  }
76
85
 
77
86
  /**
@@ -90,6 +99,15 @@ export type DefaultEvent = {
90
99
  /** Memoised `HandlerDeps`; built on the first cold-start invocation. */
91
100
  let cachedDeps: HandlerDeps | undefined;
92
101
 
102
+ /**
103
+ * In-flight cold-start build. `buildDepsFromEnv` is async (it scans the
104
+ * `Accounts` DynamoDB table for SASL credentials), so under concurrent
105
+ * warm-up two events could race past the `cachedDeps` check and each
106
+ * trigger a full scan. Memoising the promise guarantees the scan runs
107
+ * exactly once per cold start.
108
+ */
109
+ let cachedDepsPromise: Promise<HandlerDeps> | undefined;
110
+
93
111
  /**
94
112
  * The Lambda entry point. Routes on `event.requestContext.routeKey`.
95
113
  *
@@ -98,7 +116,11 @@ let cachedDeps: HandlerDeps | undefined;
98
116
  */
99
117
  export const handler = async (event: WebSocketEvent): Promise<LambdaResponse> => {
100
118
  if (cachedDeps === undefined) {
101
- cachedDeps = buildDepsFromEnv();
119
+ if (cachedDepsPromise === undefined) {
120
+ cachedDepsPromise = buildDepsFromEnv();
121
+ }
122
+ cachedDeps = await cachedDepsPromise;
123
+ cachedDepsPromise = undefined;
102
124
  }
103
125
  const deps = cachedDeps;
104
126
  return dispatch(event, deps);
@@ -125,6 +147,7 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
125
147
  tables: deps.tables,
126
148
  connectionId: connId,
127
149
  managementApi: deps.managementApi,
150
+ quitMessage: deps.serverConfig.quitMessage,
128
151
  });
129
152
  return { statusCode: 200 };
130
153
 
@@ -141,6 +164,7 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
141
164
  serverConfig: deps.serverConfig,
142
165
  motd: deps.motd,
143
166
  messages: deps.messages,
167
+ ...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
144
168
  managementApi: deps.managementApi,
145
169
  });
146
170
  return result;
@@ -159,6 +183,7 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
159
183
  */
160
184
  export function __resetHandlerCacheForTests(): void {
161
185
  cachedDeps = undefined;
186
+ cachedDepsPromise = undefined;
162
187
  }
163
188
 
164
189
  /**
@@ -172,7 +197,11 @@ export function __resetHandlerCacheForTests(): void {
172
197
  */
173
198
  export async function sweeperHandler(_event: unknown): Promise<SweepResult> {
174
199
  if (cachedDeps === undefined) {
175
- cachedDeps = buildDepsFromEnv();
200
+ if (cachedDepsPromise === undefined) {
201
+ cachedDepsPromise = buildDepsFromEnv();
202
+ }
203
+ cachedDeps = await cachedDepsPromise;
204
+ cachedDepsPromise = undefined;
176
205
  }
177
206
  const deps = cachedDeps;
178
207
  return handleSweep({ dynamo: deps.dynamo, tables: deps.tables });
@@ -190,7 +219,11 @@ export async function sweeperHandler(_event: unknown): Promise<SweepResult> {
190
219
  */
191
220
  export async function pingCheckerHandler(_event: unknown): Promise<PingCheckResult> {
192
221
  if (cachedDeps === undefined) {
193
- cachedDeps = buildDepsFromEnv();
222
+ if (cachedDepsPromise === undefined) {
223
+ cachedDepsPromise = buildDepsFromEnv();
224
+ }
225
+ cachedDeps = await cachedDepsPromise;
226
+ cachedDepsPromise = undefined;
194
227
  }
195
228
  const deps = cachedDeps;
196
229
  return handlePingCheck({
@@ -216,9 +249,12 @@ export {
216
249
  * Builds a {@link HandlerDeps} from the current `process.env`.
217
250
  *
218
251
  * Exposed so tests can construct production-shaped deps without re-running
219
- * the env parsing.
252
+ * the env parsing. Async because it scans the `Accounts` DynamoDB table
253
+ * (via {@link resolveAccountStore}) to pre-load SASL credentials at cold
254
+ * start — the {@link AccountStore} port is synchronous, so the scan must
255
+ * complete before any reducer calls `verify`.
220
256
  */
221
- export function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): HandlerDeps {
257
+ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<HandlerDeps> {
222
258
  const cfg = loadServerConfigFromLambdaEnv(env as LambdaConfigEnv);
223
259
  const tables = tablesConfigFromEnv(env);
224
260
  const region = typeof env.AWS_REGION === 'string' ? env.AWS_REGION : undefined;
@@ -231,7 +267,16 @@ export function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): HandlerD
231
267
  }
232
268
  const motd = cfg.motdLines.length > 0 ? new StaticMotdProvider(cfg.motdLines) : EmptyMotdProvider;
233
269
  const messages = bindMessageStore(cfg);
234
- return { dynamo, tables, serverConfig: cfg, motd, managementApi, messages };
270
+ const accounts = await resolveAccountStore(dynamo, tables.Accounts, cfg);
271
+ return {
272
+ dynamo,
273
+ tables,
274
+ serverConfig: cfg,
275
+ motd,
276
+ managementApi,
277
+ messages,
278
+ ...(accounts !== undefined ? { accounts } : {}),
279
+ };
235
280
  }
236
281
 
237
282
  function tablesConfigFromEnv(env: NodeJS.ProcessEnv): TablesConfig {
@@ -16,6 +16,13 @@ export { cleanupConnection } from './aws-runtime.js';
16
16
  export { TABLE_KEYS, TABLE_NAMES, tablesConfigFromNames } from './tables.js';
17
17
  export type { TableName, TablesConfig } from './tables.js';
18
18
  export { createDynamoDocumentClient } from './dynamo.js';
19
+ export {
20
+ DynamoAccountStore,
21
+ type HashedAccountCredential,
22
+ hashAccountCredential,
23
+ loadDynamoAccountStore,
24
+ } from './dynamo-account-store.js';
25
+ export { bindAccountStore, putAccountCredential, resolveAccountStore } from './account-store.js';
19
26
  export {
20
27
  loadServerConfigFromLambdaEnv,
21
28
  type LambdaConfigEnv,
@@ -0,0 +1,171 @@
1
+ /**
2
+ * DynamoDB-backed integration tests for the `Accounts` table path.
3
+ * Exercises the real DynamoDB round-trip that pure unit
4
+ * tests cannot: `putAccountCredential` writes a hashed row,
5
+ * `loadDynamoAccountStore` scans it back, and `resolveAccountStore`
6
+ * applies the table-then-config precedence.
7
+ *
8
+ * Gated on DynamoDB Local availability (same `DDB_AVAILABLE` flag as
9
+ * `handlers.test.ts`).
10
+ */
11
+
12
+ import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
13
+ import { DEFAULT_SERVER_CONFIG } from '@serverless-ircd/irc-core';
14
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15
+ import {
16
+ loadDynamoAccountStore,
17
+ putAccountCredential,
18
+ resolveAccountStore,
19
+ } from '../src/account-store.js';
20
+ import { TABLE_DEFS } from '../src/cdk-table-defs.js';
21
+ import { createDynamoDocumentClient } from '../src/dynamo.js';
22
+
23
+ const available = process.env.DDB_AVAILABLE === '1';
24
+ const endpoint = process.env.DYNAMO_ENDPOINT;
25
+
26
+ interface Fx {
27
+ tableName: string;
28
+ client: ReturnType<typeof createDynamoDocumentClient>;
29
+ cleanup: () => Promise<void>;
30
+ }
31
+
32
+ let fx: Fx | null = null;
33
+
34
+ async function makeFx(): Promise<Fx> {
35
+ if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
36
+ const tableName = `acct-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
37
+ const client = createDynamoDocumentClient({ endpoint });
38
+ const props = TABLE_DEFS.Accounts;
39
+ const pkName = props.partitionKey?.name;
40
+ if (pkName === undefined) throw new Error('Accounts table missing partitionKey');
41
+ await client.send(
42
+ new CreateTableCommand({
43
+ TableName: tableName,
44
+ AttributeDefinitions: [{ AttributeName: pkName, AttributeType: 'S' }],
45
+ KeySchema: [{ AttributeName: pkName, KeyType: 'HASH' }],
46
+ BillingMode: 'PAY_PER_REQUEST',
47
+ }),
48
+ );
49
+ return {
50
+ tableName,
51
+ client,
52
+ cleanup: async () => {
53
+ try {
54
+ await client.send(new DeleteTableCommand({ TableName: tableName }));
55
+ } catch {
56
+ // Idempotent.
57
+ }
58
+ },
59
+ };
60
+ }
61
+
62
+ describe.skipIf(!available)('Accounts table round-trip', () => {
63
+ beforeEach(async () => {
64
+ fx = await makeFx();
65
+ });
66
+ afterEach(async () => {
67
+ if (fx) await fx.cleanup();
68
+ fx = null;
69
+ });
70
+
71
+ it('putAccountCredential then loadDynamoAccountStore verifies the account', async () => {
72
+ await putAccountCredential(fx!.client, fx!.tableName, 'alice', 's3cret');
73
+ const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
74
+ expect(store).toBeDefined();
75
+ expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 's3cret' })).toEqual(
76
+ {
77
+ ok: true,
78
+ account: 'alice',
79
+ },
80
+ );
81
+ });
82
+
83
+ it('loadDynamoAccountStore returns undefined when the table has no rows', async () => {
84
+ const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
85
+ expect(store).toBeUndefined();
86
+ });
87
+
88
+ it('putAccountCredential writes multiple distinct accounts', async () => {
89
+ await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'a1');
90
+ await putAccountCredential(fx!.client, fx!.tableName, 'bob', 'b2');
91
+ const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
92
+ expect(store).toBeDefined();
93
+ expect(store!.size).toBe(2);
94
+ expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'a1' }).ok).toBe(
95
+ true,
96
+ );
97
+ expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'b2' }).ok).toBe(true);
98
+ });
99
+
100
+ it('putAccountCredential overwrites an existing account (re-seed)', async () => {
101
+ await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'old');
102
+ await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'new');
103
+ const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
104
+ expect(store).toBeDefined();
105
+ expect(store!.size).toBe(1);
106
+ expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'old' }).ok).toBe(
107
+ false,
108
+ );
109
+ expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'new' }).ok).toBe(
110
+ true,
111
+ );
112
+ });
113
+
114
+ it('rejects a wrong password after round-trip', async () => {
115
+ await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'right');
116
+ const store = await loadDynamoAccountStore(fx!.client, fx!.tableName);
117
+ expect(store).toBeDefined();
118
+ expect(
119
+ store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok,
120
+ ).toBe(false);
121
+ });
122
+ });
123
+
124
+ describe.skipIf(!available)('resolveAccountStore precedence', () => {
125
+ beforeEach(async () => {
126
+ fx = await makeFx();
127
+ });
128
+ afterEach(async () => {
129
+ if (fx) await fx.cleanup();
130
+ fx = null;
131
+ });
132
+
133
+ it('returns a DynamoAccountStore when the table has rows (table wins)', async () => {
134
+ await putAccountCredential(fx!.client, fx!.tableName, 'alice', 'table-secret');
135
+ const cfgWithSasl = {
136
+ ...DEFAULT_SERVER_CONFIG,
137
+ saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
138
+ };
139
+ const store = await resolveAccountStore(fx!.client, fx!.tableName, cfgWithSasl);
140
+ expect(store).toBeDefined();
141
+ // Table account verifies; config account does NOT (table is authoritative).
142
+ expect(
143
+ store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'table-secret' }).ok,
144
+ ).toBe(true);
145
+ expect(
146
+ store!.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
147
+ ).toBe(false);
148
+ });
149
+
150
+ it('falls back to the config-seeded store when the table is empty', async () => {
151
+ const cfgWithSasl = {
152
+ ...DEFAULT_SERVER_CONFIG,
153
+ saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
154
+ };
155
+ const store = await resolveAccountStore(fx!.client, fx!.tableName, cfgWithSasl);
156
+ expect(store).toBeDefined();
157
+ expect(
158
+ store!.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
159
+ ).toBe(true);
160
+ });
161
+
162
+ it('returns undefined when the table is empty and no config accounts are set', async () => {
163
+ const store = await resolveAccountStore(fx!.client, fx!.tableName, DEFAULT_SERVER_CONFIG);
164
+ expect(store).toBeUndefined();
165
+ });
166
+
167
+ it('returns undefined when the table is empty and serverConfig is undefined', async () => {
168
+ const store = await resolveAccountStore(fx!.client, fx!.tableName, undefined);
169
+ expect(store).toBeUndefined();
170
+ });
171
+ });
@@ -0,0 +1,280 @@
1
+ import { mockClient } from 'aws-sdk-client-mock';
2
+ import { DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
3
+ import { type SaslPayload, DEFAULT_SERVER_CONFIG, type ParsedServerConfig } from '@serverless-ircd/irc-core';
4
+ import { describe, expect, it } from 'vitest';
5
+ import {
6
+ DynamoAccountStore,
7
+ bindAccountStore,
8
+ hashAccountCredential,
9
+ type HashedAccountCredential,
10
+ putAccountCredential,
11
+ resolveAccountStore,
12
+ } from '../src/account-store';
13
+
14
+ function plainPayload(username: string, password: string): SaslPayload {
15
+ return { kind: 'PLAIN', username, password };
16
+ }
17
+
18
+ /** Builds a minimal parsed config with the supplied saslAccounts. */
19
+ function configWith(accounts: Array<{ username: string; password: string }>): ParsedServerConfig {
20
+ return { ...DEFAULT_SERVER_CONFIG, saslAccounts: accounts };
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // bindAccountStore — config-seeded fallback
25
+ // ---------------------------------------------------------------------------
26
+
27
+ describe('bindAccountStore', () => {
28
+ it('returns an AccountStore that accepts valid PLAIN creds', () => {
29
+ const store = bindAccountStore(configWith([{ username: 'alice', password: 'secret' }]));
30
+ expect(store).toBeDefined();
31
+ const out = store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' });
32
+ expect(out).toEqual({ ok: true, account: 'alice' });
33
+ });
34
+
35
+ it('returns undefined when the account list is empty', () => {
36
+ expect(bindAccountStore(configWith([]))).toBeUndefined();
37
+ });
38
+
39
+ it('returns undefined when serverConfig is undefined', () => {
40
+ expect(bindAccountStore(undefined)).toBeUndefined();
41
+ });
42
+
43
+ it('rejects wrong credentials', () => {
44
+ const store = bindAccountStore(configWith([{ username: 'alice', password: 'secret' }]))!;
45
+ expect(store.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
46
+ false,
47
+ );
48
+ });
49
+ });
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // hashAccountCredential — pure scrypt hashing helper
53
+ // ---------------------------------------------------------------------------
54
+
55
+ describe('hashAccountCredential', () => {
56
+ it('produces an entry keyed by the username with scrypt', () => {
57
+ const entry = hashAccountCredential('alice', 'secret');
58
+ expect(entry.account).toBe('alice');
59
+ expect(entry.algorithm).toBe('scrypt');
60
+ expect(entry.salt.length).toBeGreaterThan(0);
61
+ expect(entry.hash.length).toBeGreaterThan(0);
62
+ });
63
+
64
+ it('never stores the plaintext password in any field', () => {
65
+ const entry = hashAccountCredential('alice', 'hunter2-secret');
66
+ expect(entry.account).not.toContain('hunter2');
67
+ expect(entry.salt).not.toContain('hunter2');
68
+ expect(entry.hash).not.toContain('hunter2');
69
+ });
70
+
71
+ it('produces different hashes for the same password under different salts', () => {
72
+ const a = hashAccountCredential('alice', 'secret', { salt: Buffer.from('aaaaaaaaaaaaaaaa') });
73
+ const b = hashAccountCredential('alice', 'secret', { salt: Buffer.from('bbbbbbbbbbbbbbbb') });
74
+ expect(a.hash).not.toBe(b.hash);
75
+ });
76
+
77
+ it('is deterministic for a fixed salt', () => {
78
+ const salt = Buffer.from('1234567890123456');
79
+ const a = hashAccountCredential('alice', 'secret', { salt });
80
+ const b = hashAccountCredential('alice', 'secret', { salt });
81
+ expect(a.hash).toBe(b.hash);
82
+ });
83
+
84
+ it('honours a custom key length', () => {
85
+ const entry = hashAccountCredential('alice', 'secret', { keyLen: 32 });
86
+ // base64 of 32 bytes → ~44 chars; base64 of 64 bytes → ~88 chars
87
+ expect(Buffer.from(entry.hash, 'base64').length).toBe(32);
88
+ });
89
+ });
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // DynamoAccountStore — synchronous verify against pre-loaded hashed entries
93
+ // ---------------------------------------------------------------------------
94
+
95
+ describe('DynamoAccountStore', () => {
96
+ it('accepts valid PLAIN credentials and returns the account name', () => {
97
+ const entry = hashAccountCredential('alice', 'secret');
98
+ const store = new DynamoAccountStore([entry]);
99
+ expect(store.verify('PLAIN', plainPayload('alice', 'secret'))).toEqual({
100
+ ok: true,
101
+ account: 'alice',
102
+ });
103
+ });
104
+
105
+ it('rejects a wrong password', () => {
106
+ const entry = hashAccountCredential('alice', 'secret');
107
+ const store = new DynamoAccountStore([entry]);
108
+ expect(store.verify('PLAIN', plainPayload('alice', 'wrong')).ok).toBe(false);
109
+ });
110
+
111
+ it('rejects an unknown username', () => {
112
+ const store = new DynamoAccountStore([hashAccountCredential('alice', 'secret')]);
113
+ expect(store.verify('PLAIN', plainPayload('bob', 'secret')).ok).toBe(false);
114
+ });
115
+
116
+ it('rejects a non-PLAIN mechanism', () => {
117
+ const store = new DynamoAccountStore([hashAccountCredential('alice', 'secret')]);
118
+ expect(store.verify('EXTERNAL', plainPayload('alice', 'secret')).ok).toBe(false);
119
+ });
120
+
121
+ it('rejects a RAW payload', () => {
122
+ const store = new DynamoAccountStore([hashAccountCredential('alice', 'secret')]);
123
+ expect(store.verify('PLAIN', { kind: 'RAW', data: 'whatever' }).ok).toBe(false);
124
+ });
125
+
126
+ it('handles multiple loaded accounts', () => {
127
+ const store = new DynamoAccountStore([
128
+ hashAccountCredential('alice', 'a-secret'),
129
+ hashAccountCredential('bob', 'b-secret'),
130
+ ]);
131
+ expect(store.verify('PLAIN', plainPayload('alice', 'a-secret'))).toEqual({
132
+ ok: true,
133
+ account: 'alice',
134
+ });
135
+ expect(store.verify('PLAIN', plainPayload('bob', 'b-secret'))).toEqual({
136
+ ok: true,
137
+ account: 'bob',
138
+ });
139
+ });
140
+
141
+ it('fails every verify when constructed with no entries', () => {
142
+ const store = new DynamoAccountStore([]);
143
+ expect(store.verify('PLAIN', plainPayload('alice', 'secret')).ok).toBe(false);
144
+ });
145
+
146
+ it('round-trips an externally-constructed HashedAccountCredential', () => {
147
+ const row: HashedAccountCredential = hashAccountCredential('carol', 'p@ss');
148
+ const store = new DynamoAccountStore([row]);
149
+ expect(store.verify('PLAIN', plainPayload('carol', 'p@ss'))).toEqual({
150
+ ok: true,
151
+ account: 'carol',
152
+ });
153
+ });
154
+
155
+ it('reports the number of loaded entries via size', () => {
156
+ const store = new DynamoAccountStore([
157
+ hashAccountCredential('alice', 'a'),
158
+ hashAccountCredential('bob', 'b'),
159
+ ]);
160
+ expect(store.size).toBe(2);
161
+ });
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // resolveAccountStore — table-then-config precedence (mock-based)
166
+ // ---------------------------------------------------------------------------
167
+
168
+ describe('resolveAccountStore', () => {
169
+ it('returns a DynamoAccountStore when the table has rows (table wins)', async () => {
170
+ const entry = hashAccountCredential('alice', 's3cret');
171
+ const ddb = mockClient(DynamoDBDocumentClient);
172
+ ddb.on(ScanCommand).resolves({
173
+ Items: [entry],
174
+ });
175
+
176
+ const fallback = bindAccountStore(configWith([{ username: 'envuser', password: 'env-pw' }]));
177
+ const store = await resolveAccountStore(
178
+ ddb as unknown as DynamoDBDocumentClient,
179
+ 'Accounts',
180
+ fallback === undefined ? undefined : { ...DEFAULT_SERVER_CONFIG, saslAccounts: [{ username: 'envuser', password: 'env-pw' }] },
181
+ );
182
+ expect(store).toBeDefined();
183
+ expect(
184
+ store!.verify('PLAIN', plainPayload('alice', 's3cret')).ok,
185
+ ).toBe(true);
186
+ // Config account does NOT verify (table is authoritative).
187
+ expect(
188
+ store!.verify('PLAIN', plainPayload('envuser', 'env-pw')).ok,
189
+ ).toBe(false);
190
+ });
191
+
192
+ it('falls back to config-seeded store when the table is empty', async () => {
193
+ const ddb = mockClient(DynamoDBDocumentClient);
194
+ ddb.on(ScanCommand).resolves({ Items: [] });
195
+
196
+ const store = await resolveAccountStore(
197
+ ddb as unknown as DynamoDBDocumentClient,
198
+ 'Accounts',
199
+ configWith([{ username: 'bob', password: 'pw' }]),
200
+ );
201
+ expect(store).toBeDefined();
202
+ expect(
203
+ store!.verify('PLAIN', plainPayload('bob', 'pw')).ok,
204
+ ).toBe(true);
205
+ });
206
+
207
+ it('falls back to config-seeded store when the scan throws', async () => {
208
+ const ddb = mockClient(DynamoDBDocumentClient);
209
+ ddb.on(ScanCommand).rejects(new Error('ResourceNotFoundException'));
210
+
211
+ const store = await resolveAccountStore(
212
+ ddb as unknown as DynamoDBDocumentClient,
213
+ 'Accounts',
214
+ configWith([{ username: 'bob', password: 'pw' }]),
215
+ );
216
+ expect(store).toBeDefined();
217
+ expect(
218
+ store!.verify('PLAIN', plainPayload('bob', 'pw')).ok,
219
+ ).toBe(true);
220
+ });
221
+
222
+ it('returns undefined when table is empty and no config accounts', async () => {
223
+ const ddb = mockClient(DynamoDBDocumentClient);
224
+ ddb.on(ScanCommand).resolves({ Items: [] });
225
+
226
+ const store = await resolveAccountStore(
227
+ ddb as unknown as DynamoDBDocumentClient,
228
+ 'Accounts',
229
+ undefined,
230
+ );
231
+ expect(store).toBeUndefined();
232
+ });
233
+ });
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // putAccountCredential — CRUD/seed entry point (mock-based)
237
+ // ---------------------------------------------------------------------------
238
+
239
+ describe('putAccountCredential', () => {
240
+ it('writes a hashed row to DynamoDB and returns the credential', async () => {
241
+ const ddb = mockClient(DynamoDBDocumentClient);
242
+ ddb.on(PutCommand).resolves({});
243
+
244
+ const entry = await putAccountCredential(
245
+ ddb as unknown as DynamoDBDocumentClient,
246
+ 'Accounts',
247
+ 'alice',
248
+ 's3cret',
249
+ );
250
+ expect(entry.account).toBe('alice');
251
+ expect(entry.algorithm).toBe('scrypt');
252
+
253
+ const calls = ddb.commandCalls(PutCommand);
254
+ expect(calls).toHaveLength(1);
255
+ const item = calls[0]!.args[0].input.Item as HashedAccountCredential;
256
+ expect(item.account).toBe('alice');
257
+ expect(item.algorithm).toBe('scrypt');
258
+ expect(item.salt).toBe(entry.salt);
259
+ expect(item.hash).toBe(entry.hash);
260
+ });
261
+
262
+ it('the written row round-trips through DynamoAccountStore', async () => {
263
+ const ddb = mockClient(DynamoDBDocumentClient);
264
+ ddb.on(PutCommand).resolves({});
265
+
266
+ const entry = await putAccountCredential(
267
+ ddb as unknown as DynamoDBDocumentClient,
268
+ 'Accounts',
269
+ 'alice',
270
+ 's3cret',
271
+ );
272
+
273
+ // Simulate reading the row back via DynamoAccountStore.
274
+ const store = new DynamoAccountStore([entry]);
275
+ expect(store.verify('PLAIN', plainPayload('alice', 's3cret'))).toEqual({
276
+ ok: true,
277
+ account: 'alice',
278
+ });
279
+ });
280
+ });