serverless-ircd 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +34 -2
- package/.github/workflows/deploy-aws.yml +1 -2
- package/.github/workflows/deploy-cf-tcp.yml +1 -5
- package/.github/workflows/deploy-cf.yml +1 -2
- package/CHANGELOG.md +435 -0
- package/README.md +206 -27
- package/apps/aws-stack/README.md +37 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +106 -13
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +49 -3
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
- package/apps/aws-stack/tests/vitest.setup.ts +18 -0
- package/apps/aws-stack/vitest.config.ts +3 -0
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/config-loader.ts +6 -7
- package/apps/cf-tcp-container/src/container-server.ts +3 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
- package/apps/cf-tcp-container/wrangler.toml +1 -1
- package/apps/cf-worker/package.json +2 -1
- package/apps/cf-worker/vitest.config.ts +1 -1
- package/apps/cf-worker/wrangler.toml +5 -3
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +6 -6
- package/apps/local-cli/src/main.ts +20 -0
- package/apps/local-cli/src/server.ts +138 -28
- package/apps/local-cli/tests/e2e.test.ts +113 -29
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
- package/biome.json +1 -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 +110 -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 +96 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
- package/docs/ADR-Index.md +15 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1275 -0
- package/docs/AWS-TCP-Deployment.md +410 -0
- package/docs/Cloudflare-Deployment-Guide.md +719 -0
- package/docs/Cloudflare-TCP-Deployment.md +510 -0
- package/docs/Home.md +15 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanExtensions.md +812 -0
- package/docs/PlanIRCv3Websocket.md +503 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +66 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +40 -2
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
- package/packages/aws-adapter/tests/account-store.test.ts +4 -7
- package/packages/aws-adapter/tests/aws-harness.ts +5 -2
- package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
- package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +100 -4
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
- package/packages/aws-adapter/tests/global-setup.ts +55 -30
- package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
- package/packages/aws-adapter/tests/handlers.test.ts +238 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +6 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +185 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/serialize.ts +25 -4
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +7 -7
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/vitest.config.ts +1 -1
- package/packages/cf-adapter/wrangler.test.toml +7 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +38 -4
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/caps/sts.ts +84 -0
- package/packages/irc-core/src/commands/account-notify.ts +53 -0
- package/packages/irc-core/src/commands/away.ts +9 -3
- package/packages/irc-core/src/commands/cap.ts +23 -5
- package/packages/irc-core/src/commands/chathistory.ts +40 -11
- package/packages/irc-core/src/commands/index.ts +42 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +59 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +12 -9
- package/packages/irc-core/src/commands/monitor.ts +327 -0
- package/packages/irc-core/src/commands/multiline.ts +256 -0
- package/packages/irc-core/src/commands/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- package/packages/irc-core/src/commands/pre-away.ts +112 -0
- package/packages/irc-core/src/commands/privmsg.ts +19 -12
- package/packages/irc-core/src/commands/read-marker.ts +108 -0
- package/packages/irc-core/src/commands/registration.ts +8 -0
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/tagmsg.ts +71 -1
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +72 -10
- package/packages/irc-core/src/effects.ts +41 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +568 -0
- package/packages/irc-core/src/protocol/index.ts +14 -0
- package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
- package/packages/irc-core/src/protocol/numerics.ts +57 -11
- package/packages/irc-core/src/protocol/outbound.ts +36 -4
- package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
- package/packages/irc-core/src/state/connection.ts +32 -1
- package/packages/irc-core/src/types.ts +120 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/away-store.test.ts +73 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
- package/packages/irc-core/tests/caps/sts.test.ts +123 -0
- package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
- package/packages/irc-core/tests/commands/away.test.ts +85 -2
- package/packages/irc-core/tests/commands/cap.test.ts +165 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
- package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
- package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
- package/packages/irc-core/tests/commands/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +129 -0
- package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
- package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
- package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
- package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
- package/packages/irc-core/tests/commands/registration.test.ts +64 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/labeled-response.test.ts +181 -0
- package/packages/irc-core/tests/message-tags.test.ts +114 -0
- package/packages/irc-core/tests/monitor-store.test.ts +200 -0
- package/packages/irc-core/tests/numerics.test.ts +90 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +699 -19
- package/packages/irc-server/src/dispatch.ts +109 -16
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +2523 -42
- package/packages/irc-server/tests/dispatch.test.ts +300 -2
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/index.ts +6 -0
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
- package/tools/load-test/package.json +33 -0
- package/tools/load-test/src/client.ts +351 -0
- package/tools/load-test/src/config.ts +313 -0
- package/tools/load-test/src/harness.ts +116 -0
- package/tools/load-test/src/main.ts +120 -0
- package/tools/load-test/src/metrics.ts +168 -0
- package/tools/load-test/src/report.ts +106 -0
- package/tools/load-test/tests/client.test.ts +212 -0
- package/tools/load-test/tests/config.test.ts +152 -0
- package/tools/load-test/tests/framing.test.ts +37 -0
- package/tools/load-test/tests/harness.test.ts +165 -0
- package/tools/load-test/tests/metrics.test.ts +174 -0
- package/tools/load-test/tests/report.test.ts +161 -0
- package/tools/load-test/tests/smoke.test.ts +67 -0
- package/tools/load-test/tsconfig.build.json +12 -0
- package/tools/load-test/tsconfig.test.json +10 -0
- package/tools/load-test/vitest.config.ts +29 -0
- package/tools/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
|
@@ -22,9 +22,12 @@ import {
|
|
|
22
22
|
type MtlsIdentityProvider,
|
|
23
23
|
type NickHistoryStore,
|
|
24
24
|
type ParsedServerConfig,
|
|
25
|
+
type ServerConfig,
|
|
25
26
|
SystemClock,
|
|
26
27
|
UuidIdFactory,
|
|
28
|
+
type WsFrameMode,
|
|
27
29
|
createConnection,
|
|
30
|
+
frameToLines,
|
|
28
31
|
} from '@serverless-ircd/irc-core';
|
|
29
32
|
import { type ActorChannelAccess, ConnectionActor } from '@serverless-ircd/irc-server';
|
|
30
33
|
import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
|
|
@@ -33,8 +36,18 @@ import {
|
|
|
33
36
|
type MarshalledConnection,
|
|
34
37
|
unmarshalConnection,
|
|
35
38
|
} from '../serialize.js';
|
|
39
|
+
import { AwsStats } from '../stats.js';
|
|
36
40
|
import type { TablesConfig } from '../tables.js';
|
|
37
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Wall-clock timestamp captured at this Lambda isolate's first module load
|
|
44
|
+
* (cold start). Used as the `uptimeStartedAt` anchor for {@link AwsStats}
|
|
45
|
+
* so `STATS u` reports uptime for the current isolate. A deployment-wide
|
|
46
|
+
* uptime would require a persistent record (future work); for now each
|
|
47
|
+
* isolate reports its own.
|
|
48
|
+
*/
|
|
49
|
+
const LAMBDA_STARTUP_AT = Date.now();
|
|
50
|
+
|
|
38
51
|
/** Parameters accepted by {@link handleDefault}. */
|
|
39
52
|
export interface DefaultParams {
|
|
40
53
|
dynamo: DynamoDBDocumentClient;
|
|
@@ -86,6 +99,12 @@ export interface DefaultParams {
|
|
|
86
99
|
ids?: IdFactory;
|
|
87
100
|
/** Optional management endpoint URL used when constructing a default client. */
|
|
88
101
|
managementEndpoint?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Config-reload source for `REHASH`. When omitted the runtime's
|
|
104
|
+
* `reloadConfig` rejects so the actor emits a graceful error-suffixed
|
|
105
|
+
* `382` and retains the prior config.
|
|
106
|
+
*/
|
|
107
|
+
configLoader?: () => Promise<ServerConfig>;
|
|
89
108
|
}
|
|
90
109
|
|
|
91
110
|
/**
|
|
@@ -112,6 +131,12 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
112
131
|
const persisted = result.Item as unknown as MarshalledConnection;
|
|
113
132
|
const state = unmarshalConnection(persisted);
|
|
114
133
|
|
|
134
|
+
// Recover the IRCv3 WebSocket frame mode persisted at `$connect`.
|
|
135
|
+
// Absent = legacy (no subprotocol negotiated). Drives both the inbound
|
|
136
|
+
// line-framing transport (spec: one IRC message per frame, never split)
|
|
137
|
+
// and the outbound delivery shape (spec: one postToConnection per line).
|
|
138
|
+
const wsMode: WsFrameMode = persisted.wsMode ?? 'legacy';
|
|
139
|
+
|
|
115
140
|
// Mark this frame's activity.
|
|
116
141
|
state.lastSeen = clock.now();
|
|
117
142
|
|
|
@@ -141,6 +166,7 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
141
166
|
handlers,
|
|
142
167
|
managementApi: params.managementApi,
|
|
143
168
|
clock,
|
|
169
|
+
...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
|
|
144
170
|
});
|
|
145
171
|
|
|
146
172
|
const channelAccess = new LambdaChannelAccess(runtime, clock.now());
|
|
@@ -150,13 +176,27 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
150
176
|
runtime,
|
|
151
177
|
channels: channelAccess,
|
|
152
178
|
serverConfig: params.serverConfig,
|
|
179
|
+
configSource: 'Secrets Manager',
|
|
153
180
|
clock,
|
|
154
181
|
ids,
|
|
155
182
|
motd: params.motd,
|
|
183
|
+
transport: { feed: (chunk) => frameToLines(chunk, wsMode) },
|
|
184
|
+
// AwsStats Scans Connections + ChannelMeta for LUSERS/STATS.
|
|
185
|
+
// `uptimeStartedAt` is captured at the Lambda's cold start so `STATS u`
|
|
186
|
+
// reports per-isolate uptime; a deployment-wide uptime would require a
|
|
187
|
+
// persistent record (future work).
|
|
188
|
+
stats: new AwsStats({
|
|
189
|
+
dynamo: params.dynamo,
|
|
190
|
+
tables: params.tables,
|
|
191
|
+
uptimeStartedAt: LAMBDA_STARTUP_AT,
|
|
192
|
+
}),
|
|
156
193
|
...(params.messages !== undefined ? { messages: params.messages } : {}),
|
|
157
194
|
...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
|
|
158
195
|
...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
|
|
159
196
|
...(params.history !== undefined ? { history: params.history } : {}),
|
|
197
|
+
// API Gateway terminates TLS before the Lambda is invoked, so every
|
|
198
|
+
// WebSocket connection is secure → user mode `S`.
|
|
199
|
+
secure: true,
|
|
160
200
|
});
|
|
161
201
|
|
|
162
202
|
try {
|
|
@@ -172,13 +212,12 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
172
212
|
await persistState(params.dynamo, params.tables, params.connectionId, state, clock.now());
|
|
173
213
|
|
|
174
214
|
// In production, deliver outbound bytes back to the caller via APIGW.
|
|
215
|
+
// Spec-mode connections (text/binary.ircv3.net) receive exactly one IRC
|
|
216
|
+
// line per `postToConnection` (no trailing CRLF); legacy connections
|
|
217
|
+
// receive every line CRLF-joined in a single message.
|
|
175
218
|
if (params.managementApi !== null && outbound.length > 0) {
|
|
176
|
-
const data = outbound.join('\r\n');
|
|
177
219
|
try {
|
|
178
|
-
await params.managementApi.
|
|
179
|
-
ConnectionId: params.connectionId,
|
|
180
|
-
Data: data,
|
|
181
|
-
});
|
|
220
|
+
await postOutbound(params.managementApi, params.connectionId, outbound, wsMode);
|
|
182
221
|
} catch {
|
|
183
222
|
// The handler still returns 200 — the actor's mutations are
|
|
184
223
|
// persisted; the caller just didn't see the reply this frame.
|
|
@@ -187,6 +226,28 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
187
226
|
return { statusCode: 200 };
|
|
188
227
|
}
|
|
189
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Delivers `lines` to `connectionId` via APIGW according to `mode`:
|
|
231
|
+
* - `legacy` — all lines joined with `\r\n` in a single `postToConnection`.
|
|
232
|
+
* - `spec-text` / `spec-binary` — one `postToConnection` per line, no
|
|
233
|
+
* trailing CRLF (the IRCv3 WebSocket contract: one IRC message per
|
|
234
|
+
* WebSocket message).
|
|
235
|
+
*/
|
|
236
|
+
async function postOutbound(
|
|
237
|
+
api: ApiGatewayManagementApi | PostToConnection,
|
|
238
|
+
connectionId: string,
|
|
239
|
+
lines: string[],
|
|
240
|
+
mode: WsFrameMode,
|
|
241
|
+
): Promise<void> {
|
|
242
|
+
if (mode === 'legacy') {
|
|
243
|
+
await api.postToConnection({ ConnectionId: connectionId, Data: lines.join('\r\n') });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
for (const line of lines) {
|
|
247
|
+
await api.postToConnection({ ConnectionId: connectionId, Data: line });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
190
251
|
/**
|
|
191
252
|
* Writes the actor's mutated `state` back to the Connections row.
|
|
192
253
|
*
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type MotdProvider,
|
|
22
22
|
type NickHistoryStore,
|
|
23
23
|
type ParsedServerConfig,
|
|
24
|
+
type ServerConfig,
|
|
24
25
|
StaticMotdProvider,
|
|
25
26
|
SystemClock,
|
|
26
27
|
} from '@serverless-ircd/irc-core';
|
|
@@ -68,6 +69,12 @@ export interface WebSocketEvent {
|
|
|
68
69
|
*/
|
|
69
70
|
identity?: { sourceIp?: string };
|
|
70
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* Request headers. APIGW populates `event.headers` on every route; the
|
|
74
|
+
* `$connect` upgrade carries the client's `Sec-WebSocket-Protocol` offer
|
|
75
|
+
* here so the handler can negotiate the IRCv3 subprotocol.
|
|
76
|
+
*/
|
|
77
|
+
headers?: Record<string, string>;
|
|
71
78
|
body?: string;
|
|
72
79
|
}
|
|
73
80
|
|
|
@@ -75,6 +82,12 @@ export interface WebSocketEvent {
|
|
|
75
82
|
export interface LambdaResponse {
|
|
76
83
|
statusCode: number;
|
|
77
84
|
body?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Response headers. The `$connect` route sets `Sec-WebSocket-Protocol`
|
|
87
|
+
* to echo the agreed IRCv3 subprotocol back to the client; the CDK
|
|
88
|
+
* integration-response maps it onto the WebSocket handshake.
|
|
89
|
+
*/
|
|
90
|
+
headers?: Record<string, string>;
|
|
78
91
|
}
|
|
79
92
|
|
|
80
93
|
/** Injectable dependencies. Tests construct one of these to bypass env loading. */
|
|
@@ -105,13 +118,24 @@ export interface HandlerDeps {
|
|
|
105
118
|
* variant is a documented follow-up.
|
|
106
119
|
*/
|
|
107
120
|
history?: NickHistoryStore;
|
|
121
|
+
/**
|
|
122
|
+
* Config-reload source for `REHASH` — re-invokes the Lambda env config
|
|
123
|
+
* loader (Secrets Manager / SSM / env vars). Bound once per cold start;
|
|
124
|
+
* each `REHASH` re-reads the live env so rotated oper creds / MOTD take
|
|
125
|
+
* effect without a redeploy. When omitted the actor's graceful failure
|
|
126
|
+
* path applies (error-suffixed `382`).
|
|
127
|
+
*/
|
|
128
|
+
configLoader?: () => Promise<ServerConfig>;
|
|
108
129
|
}
|
|
109
130
|
|
|
110
131
|
/**
|
|
111
132
|
* Strongly-typed event discriminated by routeKey. Useful for tests that
|
|
112
133
|
* construct events directly.
|
|
113
134
|
*/
|
|
114
|
-
export type ConnectEvent = {
|
|
135
|
+
export type ConnectEvent = {
|
|
136
|
+
requestContext: { routeKey: '$connect'; connectionId: string };
|
|
137
|
+
headers?: Record<string, string>;
|
|
138
|
+
};
|
|
115
139
|
export type DisconnectEvent = {
|
|
116
140
|
requestContext: { routeKey: '$disconnect'; connectionId: string };
|
|
117
141
|
};
|
|
@@ -162,8 +186,17 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
|
|
|
162
186
|
tables: deps.tables,
|
|
163
187
|
connectionId: connId,
|
|
164
188
|
serverConfig: deps.serverConfig,
|
|
189
|
+
secWebSocketProtocol: event.headers?.['Sec-WebSocket-Protocol'] ?? null,
|
|
165
190
|
});
|
|
166
|
-
if (outcome.admitted)
|
|
191
|
+
if (outcome.admitted) {
|
|
192
|
+
if (outcome.subprotocol !== null) {
|
|
193
|
+
return {
|
|
194
|
+
statusCode: 200,
|
|
195
|
+
headers: { 'Sec-WebSocket-Protocol': outcome.subprotocol },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return { statusCode: 200 };
|
|
199
|
+
}
|
|
167
200
|
return { statusCode: outcome.statusCode, body: outcome.reason };
|
|
168
201
|
}
|
|
169
202
|
|
|
@@ -192,6 +225,7 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
|
|
|
192
225
|
messages: deps.messages,
|
|
193
226
|
...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
|
|
194
227
|
...(deps.history !== undefined ? { history: deps.history } : {}),
|
|
228
|
+
...(deps.configLoader !== undefined ? { configLoader: deps.configLoader } : {}),
|
|
195
229
|
managementApi: deps.managementApi,
|
|
196
230
|
});
|
|
197
231
|
return result;
|
|
@@ -333,6 +367,10 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
|
|
|
333
367
|
const messages = bindMessageStore(cfg);
|
|
334
368
|
const history = new InMemoryNickHistoryStore(SystemClock);
|
|
335
369
|
const accounts = await resolveAccountStore(dynamo, tables.Accounts, cfg);
|
|
370
|
+
// REHASH reload source: re-read the live env so a rotated oper password
|
|
371
|
+
// or MOTD takes effect on the next REHASH without a redeploy.
|
|
372
|
+
const configLoader = async (): Promise<ServerConfig> =>
|
|
373
|
+
loadServerConfigFromLambdaEnv(env as LambdaConfigEnv);
|
|
336
374
|
return {
|
|
337
375
|
dynamo,
|
|
338
376
|
tables,
|
|
@@ -341,6 +379,7 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
|
|
|
341
379
|
managementApi,
|
|
342
380
|
messages,
|
|
343
381
|
history,
|
|
382
|
+
configLoader,
|
|
344
383
|
...(accounts !== undefined ? { accounts } : {}),
|
|
345
384
|
};
|
|
346
385
|
}
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
type MotdProvider,
|
|
54
54
|
type NickHistoryStore,
|
|
55
55
|
type ParsedServerConfig,
|
|
56
|
+
type ServerConfig,
|
|
56
57
|
SystemClock,
|
|
57
58
|
UuidIdFactory,
|
|
58
59
|
} from '@serverless-ircd/irc-core';
|
|
@@ -151,6 +152,19 @@ export interface NlbStreamParams {
|
|
|
151
152
|
clock?: Clock;
|
|
152
153
|
/** Injected for tests; defaults to {@link UuidIdFactory}. */
|
|
153
154
|
ids?: IdFactory;
|
|
155
|
+
/**
|
|
156
|
+
* Config-reload source for `REHASH` (same as the wss `$default` path).
|
|
157
|
+
* When omitted the runtime's `reloadConfig` rejects so the actor emits a
|
|
158
|
+
* graceful error-suffixed `382`.
|
|
159
|
+
*/
|
|
160
|
+
configLoader?: () => Promise<ServerConfig>;
|
|
161
|
+
/**
|
|
162
|
+
* Whether the NLB listener terminates TLS for this flow. Set to `true`
|
|
163
|
+
* when the target group fronts a TLS listener (the `irc+tls://` port) so
|
|
164
|
+
* the connection surfaces user mode `S`; omit / set `false` for a plain
|
|
165
|
+
* TCP listener. The stack code sets this from the listener protocol.
|
|
166
|
+
*/
|
|
167
|
+
secure?: boolean;
|
|
154
168
|
}
|
|
155
169
|
|
|
156
170
|
/**
|
|
@@ -225,6 +239,7 @@ export async function handleNlbStream(
|
|
|
225
239
|
handlers,
|
|
226
240
|
managementApi: params.managementApi,
|
|
227
241
|
clock,
|
|
242
|
+
...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
|
|
228
243
|
});
|
|
229
244
|
|
|
230
245
|
const channelAccess = new NlbChannelAccess(runtime, now);
|
|
@@ -234,6 +249,7 @@ export async function handleNlbStream(
|
|
|
234
249
|
runtime,
|
|
235
250
|
channels: channelAccess,
|
|
236
251
|
serverConfig: params.serverConfig,
|
|
252
|
+
configSource: 'Secrets Manager',
|
|
237
253
|
clock,
|
|
238
254
|
ids,
|
|
239
255
|
motd: params.motd,
|
|
@@ -244,6 +260,8 @@ export async function handleNlbStream(
|
|
|
244
260
|
...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
|
|
245
261
|
...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
|
|
246
262
|
...(params.history !== undefined ? { history: params.history } : {}),
|
|
263
|
+
// Surface TLS fact when the NLB listener terminates TLS (irc+tls port).
|
|
264
|
+
...(params.secure === true ? { secure: true } : {}),
|
|
247
265
|
});
|
|
248
266
|
|
|
249
267
|
if (lines.length > 0) {
|
|
@@ -23,6 +23,8 @@ export {
|
|
|
23
23
|
loadDynamoAccountStore,
|
|
24
24
|
} from './dynamo-account-store.js';
|
|
25
25
|
export { bindAccountStore, putAccountCredential, resolveAccountStore } from './account-store.js';
|
|
26
|
+
export { AwsStats } from './stats.js';
|
|
27
|
+
export type { AwsStatsOptions } from './stats.js';
|
|
26
28
|
export {
|
|
27
29
|
loadServerConfigFromLambdaEnv,
|
|
28
30
|
type LambdaConfigEnv,
|
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
RegistrationState,
|
|
31
31
|
RosterEntry,
|
|
32
32
|
UserModes,
|
|
33
|
+
WsFrameMode,
|
|
33
34
|
} from '@serverless-ircd/irc-core';
|
|
34
35
|
|
|
35
36
|
// ---------------------------------------------------------------------------
|
|
@@ -55,6 +56,13 @@ export interface MarshalledConnection {
|
|
|
55
56
|
connectionId: ConnId;
|
|
56
57
|
registration: RegistrationState;
|
|
57
58
|
capNegotiating: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Transport TLS fact (user mode `S`). Optional on the persisted shape so
|
|
61
|
+
* rows written before the field shipped deserialize with a `false`
|
|
62
|
+
* default via the coalescing in {@link unmarshalConnection}. Always
|
|
63
|
+
* written by {@link marshalConnection}.
|
|
64
|
+
*/
|
|
65
|
+
secure?: boolean;
|
|
58
66
|
caps: string[];
|
|
59
67
|
/**
|
|
60
68
|
* Present (as a JS `Set`, auto-marshalled to `SS`) only when the
|
|
@@ -76,6 +84,15 @@ export interface MarshalledConnection {
|
|
|
76
84
|
away?: string;
|
|
77
85
|
saslMech?: string;
|
|
78
86
|
saslBuffer?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Negotiated IRCv3 WebSocket frame mode persisted at `$connect` time.
|
|
89
|
+
* Absent (`undefined`) on legacy connections (no subprotocol agreed) so
|
|
90
|
+
* the DynamoDB column is null by default — mirroring the CF adapter's
|
|
91
|
+
* hibernation-tag default. Set once and never mutated: `$default` uses
|
|
92
|
+
* `UpdateCommand` for its partial writes, so this field survives every
|
|
93
|
+
* subsequent frame.
|
|
94
|
+
*/
|
|
95
|
+
wsMode?: WsFrameMode;
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
/**
|
|
@@ -92,6 +109,7 @@ export function marshalConnection(state: ConnectionState, idleSince: number): Ma
|
|
|
92
109
|
connectionId: state.id,
|
|
93
110
|
registration: state.registration,
|
|
94
111
|
capNegotiating: state.capNegotiating,
|
|
112
|
+
secure: state.secure,
|
|
95
113
|
caps: [...state.caps],
|
|
96
114
|
userModes: { ...state.userModes },
|
|
97
115
|
lastSeen: state.lastSeen,
|
|
@@ -118,7 +136,7 @@ export function marshalConnection(state: ConnectionState, idleSince: number): Ma
|
|
|
118
136
|
/**
|
|
119
137
|
* Materialises a {@link ConnectionState} from a stored row. Rehydrates
|
|
120
138
|
* `caps` and `joinedChannels` into Sets. Throws on unrecognised
|
|
121
|
-
* `version` values (forward migrations
|
|
139
|
+
* `version` values (forward migrations are not yet implemented).
|
|
122
140
|
*/
|
|
123
141
|
export function unmarshalConnection(row: MarshalledConnection): ConnectionState {
|
|
124
142
|
if (row.version > CONNECTION_VERSION) {
|
|
@@ -130,13 +148,20 @@ export function unmarshalConnection(row: MarshalledConnection): ConnectionState
|
|
|
130
148
|
id: row.connectionId,
|
|
131
149
|
registration: row.registration,
|
|
132
150
|
capNegotiating: row.capNegotiating,
|
|
151
|
+
// `secure` is absent on rows persisted before user mode `S` shipped;
|
|
152
|
+
// default to false (plain transport) so the state shape stays satisfied.
|
|
153
|
+
secure: row.secure ?? false,
|
|
133
154
|
caps: new Set<string>(row.caps),
|
|
134
155
|
// `joinedChannels` is absent on rows that never joined a channel
|
|
135
156
|
// (DynamoDB rejects empty sets); default to an empty Set. The SDK
|
|
136
157
|
// unmarshalls `SS` back to a native Set, which the Set constructor
|
|
137
158
|
// copies; arrays (older rows) also iterate fine.
|
|
138
159
|
joinedChannels: new Set<ChanName>(row.joinedChannels ?? []),
|
|
139
|
-
userModes
|
|
160
|
+
// `userModes.tls` is absent on rows persisted before user mode `S`
|
|
161
|
+
// shipped; default to false so the field is a real boolean rather than
|
|
162
|
+
// `undefined`. Cast to Partial so TypeScript accepts the explicit
|
|
163
|
+
// default without flagging the spread overwrite (TS2783).
|
|
164
|
+
userModes: normalizeUserModes(row.userModes),
|
|
140
165
|
lastSeen: row.lastSeen,
|
|
141
166
|
connectedSince: row.connectedSince,
|
|
142
167
|
};
|
|
@@ -152,6 +177,19 @@ export function unmarshalConnection(row: MarshalledConnection): ConnectionState
|
|
|
152
177
|
return state;
|
|
153
178
|
}
|
|
154
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Defaults `tls` to false on a deserialized `UserModes`. Rows persisted
|
|
182
|
+
* before user mode `S` shipped lack the field; the DynamoDB SDK round-trip
|
|
183
|
+
* yields `undefined`, which would leak into runtime code expecting a real
|
|
184
|
+
* boolean. Casting to `Partial` lets the explicit default apply without
|
|
185
|
+
* TypeScript flagging the spread overwrite (TS2783).
|
|
186
|
+
*/
|
|
187
|
+
function normalizeUserModes(um: UserModes): UserModes {
|
|
188
|
+
const out: Partial<UserModes> = { ...um };
|
|
189
|
+
if (out.tls === undefined) out.tls = false;
|
|
190
|
+
return out as UserModes;
|
|
191
|
+
}
|
|
192
|
+
|
|
155
193
|
// ---------------------------------------------------------------------------
|
|
156
194
|
// Nicks
|
|
157
195
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS-flavoured {@link ServerStats} backend.
|
|
3
|
+
*
|
|
4
|
+
* Aggregates the network-wide counts the `LUSERS` / `STATS` reducers need
|
|
5
|
+
* directly from DynamoDB: a single `Scan` over `Connections` (the source
|
|
6
|
+
* of truth for every live connection) and one over `ChannelMeta` (one row
|
|
7
|
+
* per channel). Classification (oper / invisible / unknown) is delegated
|
|
8
|
+
* to the shared {@link computeStatsSnapshot} helper in irc-core so the
|
|
9
|
+
* classification rules live in exactly one place.
|
|
10
|
+
*
|
|
11
|
+
* Constructed per `LUSERS` / `STATS` invocation alongside the
|
|
12
|
+
* {@link AwsRuntime}; cheap to build, no caching. `uptimeStartedAt` is
|
|
13
|
+
* supplied by the caller (typically the Lambda's cold-start timestamp,
|
|
14
|
+
* stashed in module scope at first import).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
18
|
+
import { ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
19
|
+
import { type ServerStatsSnapshot, computeStatsSnapshot } from '@serverless-ircd/irc-core';
|
|
20
|
+
import type { MarshalledConnection } from './serialize.js';
|
|
21
|
+
import { unmarshalConnection } from './serialize.js';
|
|
22
|
+
import type { TablesConfig } from './tables.js';
|
|
23
|
+
|
|
24
|
+
export interface AwsStatsOptions {
|
|
25
|
+
/** DynamoDB DocumentClient (the same client used by {@link AwsRuntime}). */
|
|
26
|
+
readonly dynamo: DynamoDBDocumentClient;
|
|
27
|
+
/** Logical→physical table-name map. */
|
|
28
|
+
readonly tables: TablesConfig;
|
|
29
|
+
/** Epoch-ms the deployment counts uptime from (Lambda cold start). */
|
|
30
|
+
readonly uptimeStartedAt: number;
|
|
31
|
+
/**
|
|
32
|
+
* Optional high-water marks for `265 RPL_LOCALUSERS` / `266
|
|
33
|
+
* RPL_GLOBALUSERS`. When omitted the live count is reported as the max
|
|
34
|
+
* (so the wire numeric never claims a max below the current count).
|
|
35
|
+
*/
|
|
36
|
+
readonly maxLocalConns?: number;
|
|
37
|
+
readonly maxGlobalConns?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class AwsStats {
|
|
41
|
+
private readonly dynamo: DynamoDBDocumentClient;
|
|
42
|
+
private readonly tables: TablesConfig;
|
|
43
|
+
private readonly uptimeStartedAt: number;
|
|
44
|
+
private readonly maxLocalConns: number | undefined;
|
|
45
|
+
private readonly maxGlobalConns: number | undefined;
|
|
46
|
+
|
|
47
|
+
constructor(opts: AwsStatsOptions) {
|
|
48
|
+
this.dynamo = opts.dynamo;
|
|
49
|
+
this.tables = opts.tables;
|
|
50
|
+
this.uptimeStartedAt = opts.uptimeStartedAt;
|
|
51
|
+
this.maxLocalConns = opts.maxLocalConns;
|
|
52
|
+
this.maxGlobalConns = opts.maxGlobalConns;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async getStats(): Promise<ServerStatsSnapshot> {
|
|
56
|
+
// Scan Connections in a single request. DynamoDB Local + production
|
|
57
|
+
// both paginate at 1MB; for the serverless-IRC scale (≤ tens of
|
|
58
|
+
// thousands of connections per deployment) a single page covers the
|
|
59
|
+
// realistic ceiling. A deployment that outgrows this should maintain
|
|
60
|
+
// rolling count records (updated on connect/disconnect) rather than
|
|
61
|
+
// Scan — a tracked follow-up.
|
|
62
|
+
const connResult = await this.dynamo.send(
|
|
63
|
+
new ScanCommand({ TableName: this.tables.Connections }),
|
|
64
|
+
);
|
|
65
|
+
const connItems = (connResult.Items ?? []) as unknown as MarshalledConnection[];
|
|
66
|
+
const connections = connItems.map(unmarshalConnection);
|
|
67
|
+
|
|
68
|
+
// Channel count: one row per channel in `ChannelMeta`, again in a
|
|
69
|
+
// single page (channel counts are small).
|
|
70
|
+
const chanResult = await this.dynamo.send(
|
|
71
|
+
new ScanCommand({ TableName: this.tables.ChannelMeta, Select: 'COUNT' }),
|
|
72
|
+
);
|
|
73
|
+
const channelCount = chanResult.Count ?? 0;
|
|
74
|
+
|
|
75
|
+
return computeStatsSnapshot(connections, channelCount, this.uptimeStartedAt, {
|
|
76
|
+
...(this.maxLocalConns !== undefined ? { maxLocalConns: this.maxLocalConns } : {}),
|
|
77
|
+
...(this.maxGlobalConns !== undefined ? { maxGlobalConns: this.maxGlobalConns } : {}),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
13
|
-
import {
|
|
13
|
+
import { PutCommand } from '@aws-sdk/lib-dynamodb';
|
|
14
|
+
import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
|
|
14
15
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
15
16
|
import {
|
|
16
17
|
loadDynamoAccountStore,
|
|
@@ -126,6 +127,48 @@ describe.skipIf(!available)('Accounts table round-trip', () => {
|
|
|
126
127
|
false,
|
|
127
128
|
);
|
|
128
129
|
});
|
|
130
|
+
|
|
131
|
+
it('silently skips rows with missing or wrong-typed credential fields', async () => {
|
|
132
|
+
// The scan path must tolerate partially-malformed rows (legacy seeds,
|
|
133
|
+
// schema drift) without throwing — it just skips them. Each branch of
|
|
134
|
+
// the per-field `typeof === 'string'` guard is exercised by writing a
|
|
135
|
+
// row where exactly one non-key field has the wrong type. (The
|
|
136
|
+
// `account` field is the partition key, so DynamoDB itself enforces
|
|
137
|
+
// its type at write time and the false branch is not exercisable
|
|
138
|
+
// through the SDK; that branch is a defensive guard against
|
|
139
|
+
// synthetically-shaped Scan results.)
|
|
140
|
+
const client = requireFx().client;
|
|
141
|
+
const table = requireFx().tableName;
|
|
142
|
+
// Well-formed row — must survive.
|
|
143
|
+
await putAccountCredential(client, table, 'good', 's3cret');
|
|
144
|
+
// Row with non-string `algorithm`.
|
|
145
|
+
await client.send(
|
|
146
|
+
new PutCommand({
|
|
147
|
+
TableName: table,
|
|
148
|
+
Item: { account: 'noalgo', algorithm: 7, salt: 's', hash: 'h' },
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
// Row with non-string `salt`.
|
|
152
|
+
await client.send(
|
|
153
|
+
new PutCommand({
|
|
154
|
+
TableName: table,
|
|
155
|
+
Item: { account: 'nosalt', algorithm: 'SCRYPT-SHA-256', salt: false, hash: 'h' },
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
// Row with non-string `hash`.
|
|
159
|
+
await client.send(
|
|
160
|
+
new PutCommand({
|
|
161
|
+
TableName: table,
|
|
162
|
+
Item: { account: 'nohash', algorithm: 'SCRYPT-SHA-256', salt: 's', hash: null },
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
const store = await loadDynamoAccountStore(client, table);
|
|
166
|
+
expect(store).toBeDefined();
|
|
167
|
+
expect(store?.size).toBe(1);
|
|
168
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'good', password: 's3cret' }).ok).toBe(
|
|
169
|
+
true,
|
|
170
|
+
);
|
|
171
|
+
});
|
|
129
172
|
});
|
|
130
173
|
|
|
131
174
|
describe.skipIf(!available)('resolveAccountStore precedence', () => {
|
|
@@ -139,10 +182,9 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
|
|
|
139
182
|
|
|
140
183
|
it('returns a DynamoAccountStore when the table has rows (table wins)', async () => {
|
|
141
184
|
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'table-secret');
|
|
142
|
-
const cfgWithSasl = {
|
|
143
|
-
...DEFAULT_SERVER_CONFIG,
|
|
185
|
+
const cfgWithSasl = makeTestServerConfig({
|
|
144
186
|
saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
|
|
145
|
-
};
|
|
187
|
+
});
|
|
146
188
|
const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
|
|
147
189
|
expect(store).toBeDefined();
|
|
148
190
|
// Table account verifies; config account does NOT (table is authoritative).
|
|
@@ -155,10 +197,9 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
|
|
|
155
197
|
});
|
|
156
198
|
|
|
157
199
|
it('falls back to the config-seeded store when the table is empty', async () => {
|
|
158
|
-
const cfgWithSasl = {
|
|
159
|
-
...DEFAULT_SERVER_CONFIG,
|
|
200
|
+
const cfgWithSasl = makeTestServerConfig({
|
|
160
201
|
saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
|
|
161
|
-
};
|
|
202
|
+
});
|
|
162
203
|
const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
|
|
163
204
|
expect(store).toBeDefined();
|
|
164
205
|
expect(
|
|
@@ -170,7 +211,7 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
|
|
|
170
211
|
const store = await resolveAccountStore(
|
|
171
212
|
requireFx().client,
|
|
172
213
|
requireFx().tableName,
|
|
173
|
-
|
|
214
|
+
makeTestServerConfig(),
|
|
174
215
|
);
|
|
175
216
|
expect(store).toBeUndefined();
|
|
176
217
|
});
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
type ParsedServerConfig,
|
|
5
|
-
type SaslPayload,
|
|
6
|
-
} from '@serverless-ircd/irc-core';
|
|
2
|
+
import type { ParsedServerConfig, SaslPayload } from '@serverless-ircd/irc-core';
|
|
3
|
+
import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
|
|
7
4
|
import { mockClient } from 'aws-sdk-client-mock';
|
|
8
5
|
import { describe, expect, it } from 'vitest';
|
|
9
6
|
import {
|
|
@@ -21,7 +18,7 @@ function plainPayload(username: string, password: string): SaslPayload {
|
|
|
21
18
|
|
|
22
19
|
/** Builds a minimal parsed config with the supplied saslAccounts. */
|
|
23
20
|
function configWith(accounts: Array<{ username: string; password: string }>): ParsedServerConfig {
|
|
24
|
-
return {
|
|
21
|
+
return makeTestServerConfig({ saslAccounts: accounts });
|
|
25
22
|
}
|
|
26
23
|
|
|
27
24
|
// ---------------------------------------------------------------------------
|
|
@@ -184,7 +181,7 @@ describe('resolveAccountStore', () => {
|
|
|
184
181
|
'Accounts',
|
|
185
182
|
fallback === undefined
|
|
186
183
|
? undefined
|
|
187
|
-
: {
|
|
184
|
+
: makeTestServerConfig({ saslAccounts: [{ username: 'envuser', password: 'env-pw' }] }),
|
|
188
185
|
);
|
|
189
186
|
expect(store).toBeDefined();
|
|
190
187
|
expect(store?.verify('PLAIN', plainPayload('alice', 's3cret')).ok).toBe(true);
|
|
@@ -53,8 +53,11 @@ const endpoint = process.env.DYNAMO_ENDPOINT;
|
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
55
|
* Factory entry — appends to a suite's FACTORIES array to opt the scenarios
|
|
56
|
-
* into the AWS matrix.
|
|
57
|
-
*
|
|
56
|
+
* into the AWS matrix. The whole describe block is gated by
|
|
57
|
+
* `describe.skipIf(process.env.DDB_AVAILABLE !== '1')` at the call site
|
|
58
|
+
* (see `aws-integration.test.ts`), so no scenarios run when DynamoDB
|
|
59
|
+
* Local is unavailable. This factory itself assumes DDB is up; its
|
|
60
|
+
* `create()` throws if `DYNAMO_ENDPOINT` was never set.
|
|
58
61
|
*/
|
|
59
62
|
export const awsHarnessFactory: IrcHarnessFactory = Object.freeze({
|
|
60
63
|
name: 'aws+ws',
|
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
* a fresh {@link AwsHarness} which mints uniquely-prefixed DynamoDB
|
|
7
7
|
* tables against the global-setup's endpoint.
|
|
8
8
|
*
|
|
9
|
-
* Gated on `process.env.DDB_AVAILABLE`: the suite is skipped
|
|
10
|
-
* DynamoDB Local cannot be booted (no Docker, no
|
|
11
|
-
* every scenario MUST pass — that is the
|
|
9
|
+
* Gated on `process.env.DDB_AVAILABLE`: the whole suite is skipped via
|
|
10
|
+
* `describe.skipIf` when DynamoDB Local cannot be booted (no Docker, no
|
|
11
|
+
* Java). When available, every scenario MUST pass — that is the
|
|
12
|
+
* acceptance criterion.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { runIrcScenarios } from '@serverless-ircd/irc-test-support';
|
|
16
|
+
import { describe } from 'vitest';
|
|
15
17
|
import { awsHarnessFactory } from './aws-harness.js';
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
describe.skipIf(process.env.DDB_AVAILABLE !== '1')('aws integration scenarios', () => {
|
|
20
|
+
runIrcScenarios([awsHarnessFactory]);
|
|
21
|
+
});
|