serverless-ircd 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +18 -14
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +95 -77
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +161 -11
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +9 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +529 -190
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +563 -54
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -29,9 +29,10 @@ function makeCtx(
|
|
|
29
29
|
conn: ConnectionState,
|
|
30
30
|
clock = new FakeClock(1_000),
|
|
31
31
|
services?: ServicesStore,
|
|
32
|
+
config: ServerConfig = serverConfig,
|
|
32
33
|
): Ctx {
|
|
33
34
|
return buildCtx({
|
|
34
|
-
serverConfig,
|
|
35
|
+
serverConfig: config,
|
|
35
36
|
clock,
|
|
36
37
|
ids: new SequentialIdFactory(),
|
|
37
38
|
motd: EmptyMotdProvider,
|
|
@@ -182,6 +183,21 @@ describe('topicReducer — writes', () => {
|
|
|
182
183
|
expect(out.state.topic?.text).toBe('a'.repeat(serverConfig.topicLen));
|
|
183
184
|
});
|
|
184
185
|
|
|
186
|
+
it('keeps an ASCII topic of exactly topicLen bytes unchanged (no truncation)', () => {
|
|
187
|
+
const chan = makeChan('#foo');
|
|
188
|
+
addMember(chan, 'c1', 'alice');
|
|
189
|
+
const conn = makeConn();
|
|
190
|
+
const ctx = makeCtx(conn, new FakeClock(1_000), undefined, {
|
|
191
|
+
...serverConfig,
|
|
192
|
+
topicLen: 64,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const topic = 'a'.repeat(64);
|
|
196
|
+
const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo', topic], tags: {} }, ctx);
|
|
197
|
+
|
|
198
|
+
expect(out.state.topic?.text).toBe(topic);
|
|
199
|
+
});
|
|
200
|
+
|
|
185
201
|
it('emits 482 ERR_CHANOPRIVSNEEDED when +t is set and the writer is a non-op member', () => {
|
|
186
202
|
const chan = makeChan('#foo');
|
|
187
203
|
chan.modes.topicLock = true;
|
|
@@ -238,9 +254,91 @@ describe('topicReducer — writes', () => {
|
|
|
238
254
|
});
|
|
239
255
|
|
|
240
256
|
// ============================================================================
|
|
241
|
-
// topicReducer —
|
|
257
|
+
// topicReducer — byte-budget truncation (TOPICLEN as a UTF-8 byte ceiling)
|
|
242
258
|
// ============================================================================
|
|
243
259
|
|
|
260
|
+
describe('topicReducer — byte-budget truncation', () => {
|
|
261
|
+
const byteCtx = (conn: ConnectionState): Ctx =>
|
|
262
|
+
makeCtx(conn, new FakeClock(1_000), undefined, { ...serverConfig, topicLen: 64 });
|
|
263
|
+
|
|
264
|
+
it('truncates a multi-byte Latin-1 topic to the byte budget, not the char budget', () => {
|
|
265
|
+
const chan = makeChan('#foo');
|
|
266
|
+
addMember(chan, 'c1', 'alice');
|
|
267
|
+
const conn = makeConn();
|
|
268
|
+
const ctx = byteCtx(conn);
|
|
269
|
+
|
|
270
|
+
// 50 `é` chars = 50 UTF-16 units (under the 64 char budget) but 100
|
|
271
|
+
// UTF-8 bytes (over the 64 byte budget). Char-based slicing keeps all
|
|
272
|
+
// 50; byte-based truncation must keep exactly 32 (64 bytes).
|
|
273
|
+
const out = topicReducer(
|
|
274
|
+
chan,
|
|
275
|
+
{ command: 'TOPIC', params: ['#foo', 'é'.repeat(50)], tags: {} },
|
|
276
|
+
ctx,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
expect(out.state.topic?.text).toBe('é'.repeat(32));
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('backs off to a complete-char boundary when the budget lands mid-CJK-char', () => {
|
|
283
|
+
const chan = makeChan('#foo');
|
|
284
|
+
addMember(chan, 'c1', 'alice');
|
|
285
|
+
const conn = makeConn();
|
|
286
|
+
const ctx = byteCtx(conn);
|
|
287
|
+
|
|
288
|
+
// `好` is 3 UTF-8 bytes. 64 bytes fits 21 whole chars (63 bytes); the
|
|
289
|
+
// 22nd would need 66 bytes, so it is dropped whole and the result is a
|
|
290
|
+
// valid-UTF-8 string 1 byte under budget — never split mid-char.
|
|
291
|
+
const out = topicReducer(
|
|
292
|
+
chan,
|
|
293
|
+
{ command: 'TOPIC', params: ['#foo', '好'.repeat(30)], tags: {} },
|
|
294
|
+
ctx,
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
expect(out.state.topic?.text).toBe('好'.repeat(21));
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('truncates an emoji topic without splitting surrogate pairs', () => {
|
|
301
|
+
const chan = makeChan('#foo');
|
|
302
|
+
addMember(chan, 'c1', 'alice');
|
|
303
|
+
const conn = makeConn();
|
|
304
|
+
const ctx = byteCtx(conn);
|
|
305
|
+
|
|
306
|
+
// `😀` is astral: 2 UTF-16 code units and 4 UTF-8 bytes each. 20 emoji
|
|
307
|
+
// = 40 UTF-16 units (under 64 chars) but 80 bytes (over budget). The
|
|
308
|
+
// byte budget fits exactly 16 emoji (64 bytes); a naive UTF-16 slice
|
|
309
|
+
// at 64 units would keep all 20, and a naive byte slice at 64 would
|
|
310
|
+
// split a surrogate pair.
|
|
311
|
+
const out = topicReducer(
|
|
312
|
+
chan,
|
|
313
|
+
{ command: 'TOPIC', params: ['#foo', '😀'.repeat(20)], tags: {} },
|
|
314
|
+
ctx,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
expect(out.state.topic?.text).toBe('😀'.repeat(16));
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('broadcasts and persists the byte-truncated text', () => {
|
|
321
|
+
const chan = makeChan('#foo');
|
|
322
|
+
addMember(chan, 'c1', 'alice');
|
|
323
|
+
const conn = makeConn();
|
|
324
|
+
const ctx = byteCtx(conn);
|
|
325
|
+
|
|
326
|
+
const out = topicReducer(
|
|
327
|
+
chan,
|
|
328
|
+
{ command: 'TOPIC', params: ['#foo', 'é'.repeat(50)], tags: {} },
|
|
329
|
+
ctx,
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const truncated = 'é'.repeat(32);
|
|
333
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
334
|
+
Effect.applyChannelDelta('#foo', {
|
|
335
|
+
topic: { text: truncated, setter: 'alice!alice@example.com', setAt: 1_000 },
|
|
336
|
+
}),
|
|
337
|
+
Effect.broadcast('#foo', [L(`:alice!alice@example.com TOPIC #foo :${truncated}`)]),
|
|
338
|
+
]);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
244
342
|
describe('topicReducer — rejections', () => {
|
|
245
343
|
it('emits 461 ERR_NEEDMOREPARAMS when no channel is supplied', () => {
|
|
246
344
|
const chan = makeChan('#foo');
|
|
@@ -279,6 +377,43 @@ describe('topicReducer — rejections', () => {
|
|
|
279
377
|
]);
|
|
280
378
|
});
|
|
281
379
|
|
|
380
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing NUL', () => {
|
|
381
|
+
const chan = makeChan('#foo');
|
|
382
|
+
const conn = makeConn();
|
|
383
|
+
const ctx = makeCtx(conn);
|
|
384
|
+
|
|
385
|
+
const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo\0bar'], tags: {} }, ctx);
|
|
386
|
+
|
|
387
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
388
|
+
Effect.send('c1', [L(':irc.example.com 403 alice #foo\0bar :No such channel')]),
|
|
389
|
+
]);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing a control character', () => {
|
|
393
|
+
const chan = makeChan('#foo');
|
|
394
|
+
const conn = makeConn();
|
|
395
|
+
const ctx = makeCtx(conn);
|
|
396
|
+
|
|
397
|
+
const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo\x1fbar'], tags: {} }, ctx);
|
|
398
|
+
|
|
399
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
400
|
+
Effect.send('c1', [L(':irc.example.com 403 alice #foo\x1fbar :No such channel')]),
|
|
401
|
+
]);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name whose UTF-8 byte size exceeds the cap', () => {
|
|
405
|
+
const long = `#${'é'.repeat(25)}`; // 51 UTF-8 bytes, only 26 UTF-16 units
|
|
406
|
+
const chan = makeChan(long);
|
|
407
|
+
const conn = makeConn();
|
|
408
|
+
const ctx = makeCtx(conn);
|
|
409
|
+
|
|
410
|
+
const out = topicReducer(chan, { command: 'TOPIC', params: [long], tags: {} }, ctx);
|
|
411
|
+
|
|
412
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
413
|
+
Effect.send('c1', [L(`:irc.example.com 403 alice ${long} :No such channel`)]),
|
|
414
|
+
]);
|
|
415
|
+
});
|
|
416
|
+
|
|
282
417
|
it('emits 403 ERR_NOSUCHCHANNEL for an empty channel name', () => {
|
|
283
418
|
const chan = makeChan('#foo');
|
|
284
419
|
const conn = makeConn();
|
|
@@ -15,12 +15,15 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { describe, expect, it } from 'vitest';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
applyAccountSuccess,
|
|
20
|
+
attemptPassAccountAuth,
|
|
21
|
+
passBasedAccountAuth,
|
|
22
|
+
} from '../../src/commands/account-auth';
|
|
19
23
|
import { NICKSERV_NICK, nickservReducer } from '../../src/commands/nickserv';
|
|
24
|
+
import { passReducer } from '../../src/commands/registration';
|
|
20
25
|
import { authenticateReducer } from '../../src/commands/sasl';
|
|
21
|
-
import { type HashedAccountCredential, hashAccountCredential } from '../../src/credential-hashing';
|
|
22
26
|
import type { Effect as EffectType } from '../../src/effects';
|
|
23
|
-
import type { AccountStore } from '../../src/ports';
|
|
24
27
|
import {
|
|
25
28
|
EmptyMotdProvider,
|
|
26
29
|
FakeClock,
|
|
@@ -71,18 +74,13 @@ function saslReadyState(nick = 'alice'): ConnectionState {
|
|
|
71
74
|
return s;
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
function makeCtx(
|
|
75
|
-
state: ConnectionState,
|
|
76
|
-
accounts?: AccountStore,
|
|
77
|
-
services?: InMemoryServicesStore,
|
|
78
|
-
): Ctx {
|
|
77
|
+
function makeCtx(state: ConnectionState, services?: InMemoryServicesStore): Ctx {
|
|
79
78
|
return buildCtx({
|
|
80
79
|
serverConfig,
|
|
81
80
|
clock: new FakeClock(NOW),
|
|
82
81
|
ids: new SequentialIdFactory(),
|
|
83
82
|
motd: EmptyMotdProvider,
|
|
84
83
|
connection: state,
|
|
85
|
-
...(accounts !== undefined ? { accounts } : {}),
|
|
86
84
|
...(services !== undefined ? { services } : {}),
|
|
87
85
|
});
|
|
88
86
|
}
|
|
@@ -119,6 +117,80 @@ function emptySnapshot(): ServicesSnapshot {
|
|
|
119
117
|
};
|
|
120
118
|
}
|
|
121
119
|
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// AC: NickServ REGISTER → PASS <nick>:<password> succeeds post-registration
|
|
122
|
+
// (T178: PASS-auth consults ServicesStore.verifyNick — the bug fix)
|
|
123
|
+
// ============================================================================
|
|
124
|
+
|
|
125
|
+
describe('T178 unified account: NickServ REGISTER → PASS-auth (post-registration)', () => {
|
|
126
|
+
it('a freshly NickServ-registered nick authenticates via PASS <nick>:<password>', () => {
|
|
127
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
128
|
+
const registrar = makeConn('c-reg', 'alice');
|
|
129
|
+
nickservReducer(
|
|
130
|
+
registrar,
|
|
131
|
+
privmsg('REGISTER hunter2 alice@example.com'),
|
|
132
|
+
makeCtx(registrar, services),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// A second connection sending PASS after registration (no AccountStore).
|
|
136
|
+
const conn = makeConn('c1', 'alice');
|
|
137
|
+
const ctx = makeCtx(conn, services);
|
|
138
|
+
const out = passReducer(conn, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
|
|
139
|
+
|
|
140
|
+
expect(conn.account).toBe('alice');
|
|
141
|
+
expect(conn.userModes.registered).toBe(true);
|
|
142
|
+
const texts = sentTexts(out.effects);
|
|
143
|
+
expect(texts).toContain(
|
|
144
|
+
`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,
|
|
145
|
+
);
|
|
146
|
+
expect(texts).toContain(`:${SV} 903 alice :SASL authentication successful`);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('PASS-auth with the wrong password after NickServ REGISTER is a silent no-op', () => {
|
|
150
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
151
|
+
const registrar = makeConn('c-reg', 'alice');
|
|
152
|
+
nickservReducer(
|
|
153
|
+
registrar,
|
|
154
|
+
privmsg('REGISTER hunter2 alice@example.com'),
|
|
155
|
+
makeCtx(registrar, services),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const conn = makeConn('c1', 'alice');
|
|
159
|
+
const ctx = makeCtx(conn, services);
|
|
160
|
+
const out = passReducer(conn, { command: 'PASS', params: ['alice:wrong'], tags: {} }, ctx);
|
|
161
|
+
|
|
162
|
+
expect(conn.account).toBeUndefined();
|
|
163
|
+
expect(out.effects).toEqual<EffectType[]>([]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('passBasedAccountAuth identifies via services.verifyNick at registration completion', () => {
|
|
167
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
168
|
+
const registrar = makeConn('c-reg', 'alice');
|
|
169
|
+
nickservReducer(
|
|
170
|
+
registrar,
|
|
171
|
+
privmsg('REGISTER hunter2 alice@example.com'),
|
|
172
|
+
makeCtx(registrar, services),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const conn = makeConn('c1', 'alice');
|
|
176
|
+
conn.registration = 'registering';
|
|
177
|
+
conn.passAttempt = 'alice:hunter2';
|
|
178
|
+
const effects = passBasedAccountAuth(conn, makeCtx(conn, services));
|
|
179
|
+
|
|
180
|
+
expect(conn.account).toBe('alice');
|
|
181
|
+
expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('attemptPassAccountAuth consults services.verifyNick directly', () => {
|
|
185
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
186
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
187
|
+
const conn = makeConn('c1', 'alice');
|
|
188
|
+
const effects = attemptPassAccountAuth(conn, makeCtx(conn, services), 'alice:hunter2');
|
|
189
|
+
expect(conn.account).toBe('alice');
|
|
190
|
+
expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
122
194
|
// ============================================================================
|
|
123
195
|
// AC: NickServ REGISTER → SASL PLAIN succeeds with the same password
|
|
124
196
|
// ============================================================================
|
|
@@ -127,12 +199,12 @@ describe('T156 unified account: NickServ REGISTER → SASL PLAIN', () => {
|
|
|
127
199
|
it('a freshly NickServ-registered nick authenticates via SASL PLAIN with no second registration', () => {
|
|
128
200
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
129
201
|
const registrar = makeConn('c-reg', 'alice');
|
|
130
|
-
const regCtx = makeCtx(registrar,
|
|
202
|
+
const regCtx = makeCtx(registrar, services);
|
|
131
203
|
nickservReducer(registrar, privmsg('REGISTER hunter2 alice@example.com'), regCtx);
|
|
132
204
|
|
|
133
205
|
// A second connection speaking SASL PLAIN with the same nick:password.
|
|
134
206
|
const saslConn = saslReadyState('alice');
|
|
135
|
-
const saslCtx = makeCtx(saslConn,
|
|
207
|
+
const saslCtx = makeCtx(saslConn, services);
|
|
136
208
|
authenticateReducer(saslConn, authenticate('PLAIN'), saslCtx);
|
|
137
209
|
const out = authenticateReducer(
|
|
138
210
|
saslConn,
|
|
@@ -155,11 +227,11 @@ describe('T156 unified account: NickServ REGISTER → SASL PLAIN', () => {
|
|
|
155
227
|
nickservReducer(
|
|
156
228
|
registrar,
|
|
157
229
|
privmsg('REGISTER hunter2 alice@example.com'),
|
|
158
|
-
makeCtx(registrar,
|
|
230
|
+
makeCtx(registrar, services),
|
|
159
231
|
);
|
|
160
232
|
|
|
161
233
|
const saslConn = saslReadyState('alice');
|
|
162
|
-
const saslCtx = makeCtx(saslConn,
|
|
234
|
+
const saslCtx = makeCtx(saslConn, services);
|
|
163
235
|
authenticateReducer(saslConn, authenticate('PLAIN'), saslCtx);
|
|
164
236
|
const out = authenticateReducer(
|
|
165
237
|
saslConn,
|
|
@@ -179,10 +251,10 @@ describe('T156 unified account: NickServ REGISTER → SASL PLAIN', () => {
|
|
|
179
251
|
describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', () => {
|
|
180
252
|
it('NickServ INFO resolves an account seeded into the unified store', () => {
|
|
181
253
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
182
|
-
services.
|
|
254
|
+
services.registerNick('alice', 'hunter2', '');
|
|
183
255
|
|
|
184
256
|
const conn = makeConn('c1', 'bob');
|
|
185
|
-
const out = nickservReducer(conn, privmsg('INFO alice'), makeCtx(conn,
|
|
257
|
+
const out = nickservReducer(conn, privmsg('INFO alice'), makeCtx(conn, services));
|
|
186
258
|
|
|
187
259
|
const texts = sentTexts(out.effects);
|
|
188
260
|
expect(texts).toContain(':NickServ!NickServ@services NOTICE bob :Nick: alice');
|
|
@@ -191,22 +263,18 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
|
|
|
191
263
|
|
|
192
264
|
it('NickServ isRegisteredNick resolves a seeded SASL account', () => {
|
|
193
265
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
194
|
-
services.
|
|
266
|
+
services.registerNick('alice', 'hunter2', '');
|
|
195
267
|
expect(services.isRegisteredNick('alice')).toBe(true);
|
|
196
268
|
expect(services.isRegisteredNick('ALICE')).toBe(true);
|
|
197
269
|
});
|
|
198
270
|
|
|
199
271
|
it('NickServ SET ENFORCE applies to a seeded SASL account once identified', () => {
|
|
200
272
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
201
|
-
services.
|
|
273
|
+
services.registerNick('alice', 'hunter2', '');
|
|
202
274
|
const conn = makeConn('c1', 'alice');
|
|
203
275
|
conn.account = 'alice';
|
|
204
276
|
conn.userModes.registered = true;
|
|
205
|
-
const out = nickservReducer(
|
|
206
|
-
conn,
|
|
207
|
-
privmsg('SET ENFORCE kill'),
|
|
208
|
-
makeCtx(conn, undefined, services),
|
|
209
|
-
);
|
|
277
|
+
const out = nickservReducer(conn, privmsg('SET ENFORCE kill'), makeCtx(conn, services));
|
|
210
278
|
|
|
211
279
|
expect(services.getNickEnforce('alice')).toBe<NickEnforcePolicy>('kill');
|
|
212
280
|
expect(sentTexts(out.effects)).toContain(
|
|
@@ -216,13 +284,9 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
|
|
|
216
284
|
|
|
217
285
|
it('NickServ IDENTIFY succeeds against a seeded SASL account', () => {
|
|
218
286
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
219
|
-
services.
|
|
287
|
+
services.registerNick('alice', 'hunter2', '');
|
|
220
288
|
const conn = makeConn('c1', 'alice');
|
|
221
|
-
const out = nickservReducer(
|
|
222
|
-
conn,
|
|
223
|
-
privmsg('IDENTIFY hunter2'),
|
|
224
|
-
makeCtx(conn, undefined, services),
|
|
225
|
-
);
|
|
289
|
+
const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), makeCtx(conn, services));
|
|
226
290
|
|
|
227
291
|
expect(conn.account).toBe('alice');
|
|
228
292
|
expect(conn.userModes.registered).toBe(true);
|
|
@@ -233,9 +297,9 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
|
|
|
233
297
|
|
|
234
298
|
it('NickServ IDENTIFY with the wrong password fails against a seeded SASL account', () => {
|
|
235
299
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
236
|
-
services.
|
|
300
|
+
services.registerNick('alice', 'hunter2', '');
|
|
237
301
|
const conn = makeConn('c1', 'alice');
|
|
238
|
-
const out = nickservReducer(conn, privmsg('IDENTIFY nope'), makeCtx(conn,
|
|
302
|
+
const out = nickservReducer(conn, privmsg('IDENTIFY nope'), makeCtx(conn, services));
|
|
239
303
|
|
|
240
304
|
expect(conn.account).toBeUndefined();
|
|
241
305
|
expect(sentTexts(out.effects)).toContain(
|
|
@@ -243,14 +307,14 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
|
|
|
243
307
|
);
|
|
244
308
|
});
|
|
245
309
|
|
|
246
|
-
it('a NickServ REGISTER does not overwrite an
|
|
310
|
+
it('a NickServ REGISTER does not overwrite an existing credential', () => {
|
|
247
311
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
248
|
-
services.
|
|
312
|
+
services.registerNick('alice', 'hunter2', '');
|
|
249
313
|
const conn = makeConn('c1', 'alice');
|
|
250
314
|
const out = nickservReducer(
|
|
251
315
|
conn,
|
|
252
316
|
privmsg('REGISTER otherpw alice@example.com'),
|
|
253
|
-
makeCtx(conn,
|
|
317
|
+
makeCtx(conn, services),
|
|
254
318
|
);
|
|
255
319
|
|
|
256
320
|
expect(sentTexts(out.effects)).toContain(
|
|
@@ -260,14 +324,6 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
|
|
|
260
324
|
expect(services.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
|
|
261
325
|
expect(services.verifyNick('alice', 'otherpw').ok).toBe(false);
|
|
262
326
|
});
|
|
263
|
-
|
|
264
|
-
it('ingestAccountCredential returns false when the nick is already registered', () => {
|
|
265
|
-
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
266
|
-
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
267
|
-
expect(services.ingestAccountCredential(hashAccountCredential('alice', 'otherpw'))).toBe(false);
|
|
268
|
-
// The original credential still verifies.
|
|
269
|
-
expect(services.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
|
|
270
|
-
});
|
|
271
327
|
});
|
|
272
328
|
|
|
273
329
|
// ============================================================================
|
|
@@ -277,15 +333,11 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
|
|
|
277
333
|
describe('T156 unified account: IDENTIFY vs SASL downstream parity', () => {
|
|
278
334
|
it('IDENTIFY delivers queued memos (parity with SASL success)', () => {
|
|
279
335
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
280
|
-
services.
|
|
336
|
+
services.registerNick('alice', 'hunter2', '');
|
|
281
337
|
services.recordMemo({ from: 'bob', to: 'alice', body: 'hello', sentAt: NOW });
|
|
282
338
|
|
|
283
339
|
const conn = makeConn('c1', 'alice');
|
|
284
|
-
const out = nickservReducer(
|
|
285
|
-
conn,
|
|
286
|
-
privmsg('IDENTIFY hunter2'),
|
|
287
|
-
makeCtx(conn, undefined, services),
|
|
288
|
-
);
|
|
340
|
+
const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), makeCtx(conn, services));
|
|
289
341
|
|
|
290
342
|
expect(sentTexts(out.effects)).toContain(
|
|
291
343
|
':MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] hello',
|
|
@@ -294,9 +346,9 @@ describe('T156 unified account: IDENTIFY vs SASL downstream parity', () => {
|
|
|
294
346
|
|
|
295
347
|
it('both paths share applyAccountSuccess for +r stamping', () => {
|
|
296
348
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
297
|
-
services.
|
|
349
|
+
services.registerNick('alice', 'hunter2', '');
|
|
298
350
|
const conn = makeConn('c1', 'alice');
|
|
299
|
-
const ctx = makeCtx(conn,
|
|
351
|
+
const ctx = makeCtx(conn, services);
|
|
300
352
|
|
|
301
353
|
const effects = applyAccountSuccess(conn, ctx, 'alice');
|
|
302
354
|
|
|
@@ -356,6 +408,9 @@ describe('T156 unified account: scrypt-hashed credentials at rest', () => {
|
|
|
356
408
|
email: 'l@x',
|
|
357
409
|
createdAt: NOW,
|
|
358
410
|
enforce: 'none',
|
|
411
|
+
certSubjects: [],
|
|
412
|
+
identifyFailures: [],
|
|
413
|
+
pendingIdentifyFailureNotice: 0,
|
|
359
414
|
// Malformed credential — empty hash, rejected by verifyHashedPassword.
|
|
360
415
|
credential: { account: 'legacy', algorithm: 'scrypt', salt: 's', hash: '' },
|
|
361
416
|
},
|
|
@@ -379,38 +434,3 @@ describe('T156 unified account: scrypt-hashed credentials at rest', () => {
|
|
|
379
434
|
expect(JSON.stringify(snap.nicks[0])).not.toContain('hunter2');
|
|
380
435
|
});
|
|
381
436
|
});
|
|
382
|
-
|
|
383
|
-
// ============================================================================
|
|
384
|
-
// Adapter ingestion contract: ingestAccountCredential accepts HashedAccountCredential
|
|
385
|
-
// ============================================================================
|
|
386
|
-
|
|
387
|
-
describe('T156 unified account: ingestAccountCredential', () => {
|
|
388
|
-
it('creates a registered nick with default email / enforce from a hashed credential', () => {
|
|
389
|
-
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
390
|
-
const cred = hashAccountCredential('alice', 'hunter2');
|
|
391
|
-
expect(services.ingestAccountCredential(cred)).toBe(true);
|
|
392
|
-
expect(services.isRegisteredNick('alice')).toBe(true);
|
|
393
|
-
const rec = services.getNick('alice');
|
|
394
|
-
expect(rec).toBeDefined();
|
|
395
|
-
if (rec !== undefined) {
|
|
396
|
-
expect(rec.account).toBe('alice');
|
|
397
|
-
expect(rec.enforce).toBe<NickEnforcePolicy>('none');
|
|
398
|
-
}
|
|
399
|
-
// verifyNick goes through the same scrypt path as registerNick.
|
|
400
|
-
expect(services.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
it('preserves the original spelling of the account name', () => {
|
|
404
|
-
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
405
|
-
const cred: HashedAccountCredential = {
|
|
406
|
-
account: 'Alice',
|
|
407
|
-
algorithm: 'scrypt',
|
|
408
|
-
salt: 's',
|
|
409
|
-
hash: 'aGVsbG8=',
|
|
410
|
-
};
|
|
411
|
-
services.ingestAccountCredential(cred);
|
|
412
|
-
const rec = services.getNick('Alice');
|
|
413
|
-
expect(rec?.nick).toBe('Alice');
|
|
414
|
-
expect(rec?.account).toBe('Alice');
|
|
415
|
-
});
|
|
416
|
-
});
|