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.
- package/CHANGELOG.md +84 -0
- package/README.md +28 -19
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +5 -1
- package/apps/cf-worker/wrangler.toml +41 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +1 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +28 -2
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +93 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/AWS-Adapter-Architecture.md +6 -4
- package/docs/AWS-Deployment.md +86 -7
- package/docs/Cloudflare-Deployment-Guide.md +5 -5
- package/docs/Home.md +11 -1
- package/docs/Release-Process.md +9 -6
- package/package.json +14 -15
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +118 -30
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +30 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/handlers/default.ts +8 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +51 -6
- package/packages/aws-adapter/src/index.ts +7 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
- package/packages/aws-adapter/tests/account-store.test.ts +280 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +55 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/handlers.test.ts +46 -0
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +42 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +29 -0
- package/packages/cf-adapter/src/connection-do.ts +85 -0
- package/packages/cf-adapter/src/env.ts +27 -1
- package/packages/cf-adapter/src/index.ts +2 -1
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
- package/packages/cf-adapter/tests/worker/main.ts +2 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +10 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +3 -2
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/commands/index.ts +2 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +4 -3
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/registration.ts +8 -7
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +26 -3
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +68 -4
- package/packages/irc-core/src/types.ts +22 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/commands/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/registration.test.ts +106 -14
- package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/config.test.ts +75 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +24 -0
- package/packages/irc-server/src/routing.ts +9 -0
- package/packages/irc-server/tests/actor.test.ts +220 -1
- package/packages/irc-server/tests/routing.test.ts +3 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +5 -4
- package/pnpm-workspace.yaml +1 -0
- package/tools/seed-aws-accounts.ts +80 -0
- package/AGENTS.md +0 -5
- package/MOTD.txt +0 -3
- package/PLAN-FIXES.md +0 -358
- package/PLAN.md +0 -420
- package/dashboards/cloudwatch-irc.json +0 -139
- package/progress.md +0 -107
- package/tickets.md +0 -2485
- package/webircgateway/LICENSE +0 -201
- package/webircgateway/Makefile +0 -44
- package/webircgateway/README.md +0 -134
- package/webircgateway/config.conf.example +0 -135
- package/webircgateway/go.mod +0 -16
- package/webircgateway/go.sum +0 -89
- package/webircgateway/main.go +0 -118
- package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
- package/webircgateway/pkg/identd/identd.go +0 -86
- package/webircgateway/pkg/identd/rpcclient.go +0 -59
- package/webircgateway/pkg/irc/isupport.go +0 -56
- package/webircgateway/pkg/irc/message.go +0 -217
- package/webircgateway/pkg/irc/state.go +0 -79
- package/webircgateway/pkg/proxy/proxy.go +0 -129
- package/webircgateway/pkg/proxy/server.go +0 -237
- package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
- package/webircgateway/pkg/webircgateway/client.go +0 -741
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
- package/webircgateway/pkg/webircgateway/config.go +0 -385
- package/webircgateway/pkg/webircgateway/gateway.go +0 -278
- package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
- package/webircgateway/pkg/webircgateway/hooks.go +0 -152
- package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
- package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
- package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
- package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
- package/webircgateway/pkg/webircgateway/utils.go +0 -147
- package/webircgateway/plugins/example/plugin.go +0 -11
- package/webircgateway/plugins/stats/plugin.go +0 -52
- package/webircgateway/staticcheck.conf +0 -1
- 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,
|
|
6
|
-
* reservation if any
|
|
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
|
-
/**
|
|
20
|
-
|
|
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
|
|
25
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|