serverless-ircd 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -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 +177 -52
- 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 +267 -92
- 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/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- 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 +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- 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 +20 -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 +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- 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/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -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 +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- 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 +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- 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/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "serverless-ircd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"typescript": "^5.9.3",
|
|
18
18
|
"vite": "^7.3.6",
|
|
19
19
|
"vitest": "^4.1.10",
|
|
20
|
-
"@serverless-ircd/aws-adapter": "0.
|
|
20
|
+
"@serverless-ircd/aws-adapter": "0.10.0"
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
23
|
"build": "turbo run build",
|
|
@@ -32,13 +32,10 @@
|
|
|
32
32
|
"mutation:commands": "pnpm --filter @serverless-ircd/irc-core mutation:commands",
|
|
33
33
|
"mutation": "pnpm run mutation:protocol && pnpm run mutation:commands",
|
|
34
34
|
"clean": "turbo run clean && rimraf node_modules",
|
|
35
|
-
"deploy:cf
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"deploy:
|
|
39
|
-
"
|
|
40
|
-
"deploy:aws:staging": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:staging",
|
|
41
|
-
"deploy:aws:prod": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:prod",
|
|
42
|
-
"smoke:aws:staging": "pnpm --filter @serverless-ircd/aws-stack smoke:staging"
|
|
35
|
+
"deploy:cf": "pnpm --filter @serverless-ircd/cf-worker run deploy",
|
|
36
|
+
"smoke:cf": "pnpm --filter @serverless-ircd/cf-worker run smoke",
|
|
37
|
+
"deploy:cf-tcp": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container run deploy",
|
|
38
|
+
"deploy:aws": "pnpm build && pnpm --filter @serverless-ircd/aws-stack run deploy",
|
|
39
|
+
"smoke:aws": "pnpm --filter @serverless-ircd/aws-stack run smoke"
|
|
43
40
|
}
|
|
44
41
|
}
|
|
@@ -459,10 +459,15 @@ export class AwsRuntime implements IrcRuntime {
|
|
|
459
459
|
// -------------------------------------------------------------------------
|
|
460
460
|
|
|
461
461
|
private async readConnectionRow(conn: ConnId): Promise<MarshalledConnection | null> {
|
|
462
|
+
// Strongly consistent: peer lookups (WHOIS, roster hydration) read a
|
|
463
|
+
// row that may have just been written by another frame. An eventually-
|
|
464
|
+
// consistent Get could present a stale peer snapshot under replication
|
|
465
|
+
// lag.
|
|
462
466
|
const result = await this.dynamo.send(
|
|
463
467
|
new GetCommand({
|
|
464
468
|
TableName: this.tables.Connections,
|
|
465
469
|
Key: { connectionId: conn },
|
|
470
|
+
ConsistentRead: true,
|
|
466
471
|
}),
|
|
467
472
|
);
|
|
468
473
|
if (result.Item === undefined) return null;
|
|
@@ -723,7 +728,16 @@ export async function cleanupConnection(
|
|
|
723
728
|
quitMessage = 'Client Quit',
|
|
724
729
|
): Promise<void> {
|
|
725
730
|
const connRow = await dynamo.send(
|
|
726
|
-
|
|
731
|
+
// Strongly consistent: $disconnect (and the send()/broadcast()
|
|
732
|
+
// GoneException path) can fire on the very next frame after the
|
|
733
|
+
// connection's own row was last written/updated. An eventually-
|
|
734
|
+
// consistent Get can miss that just-written row under DynamoDB
|
|
735
|
+
// replication lag and skip nick release + roster fanout.
|
|
736
|
+
new GetCommand({
|
|
737
|
+
TableName: tables.Connections,
|
|
738
|
+
Key: { connectionId: connId },
|
|
739
|
+
ConsistentRead: true,
|
|
740
|
+
}),
|
|
727
741
|
);
|
|
728
742
|
const item = connRow.Item as unknown as MarshalledConnection | undefined;
|
|
729
743
|
if (item === undefined) return; // idempotent — already cleaned up
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
* so a future `TransactWriteItems` can span Connections ↔ membership
|
|
19
19
|
* atomically. (Atomicity across Connections/ChannelMembers lands in the
|
|
20
20
|
* membership-transaction follow-up.)
|
|
21
|
-
* •
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* • SASL / NickServ credentials live in the `Services` table under the
|
|
22
|
+
* `NICK:<fold>` partition. The standalone `Accounts` table was dropped
|
|
23
|
+
* — `ServicesStore` (`registerNick` / `verifyNick` / `verifyCertFP`)
|
|
24
|
+
* is the single credential home for SASL PLAIN, SASL EXTERNAL,
|
|
25
|
+
* PASS-auth, and NickServ IDENTIFY.
|
|
26
26
|
* • `Connections.idleSince` is the DynamoDB TTL attribute; idle connections
|
|
27
27
|
* are reaped automatically in addition to the explicit sweeper.
|
|
28
28
|
*/
|
|
@@ -32,7 +32,7 @@ import type { TableProps } from 'aws-cdk-lib/aws-dynamodb';
|
|
|
32
32
|
import type { TableName } from './tables.js';
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* All
|
|
35
|
+
* All four tables keyed by their logical construct id. The id doubles as the
|
|
36
36
|
* CloudFormation logical id; the physical `TableName` is set on each entry.
|
|
37
37
|
*
|
|
38
38
|
* NOTE: the deployed physical name is **environment-prefixed** by
|
|
@@ -65,11 +65,6 @@ export const TABLE_DEFS: Record<TableName, TableProps> = {
|
|
|
65
65
|
partitionKey: { name: 'nickLower', type: AttributeType.STRING },
|
|
66
66
|
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
67
67
|
},
|
|
68
|
-
Accounts: {
|
|
69
|
-
tableName: 'Accounts',
|
|
70
|
-
partitionKey: { name: 'account', type: AttributeType.STRING },
|
|
71
|
-
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
72
|
-
},
|
|
73
68
|
Services: {
|
|
74
69
|
tableName: 'Services',
|
|
75
70
|
partitionKey: { name: 'pk', type: AttributeType.STRING },
|
|
@@ -53,10 +53,27 @@ export interface LambdaConfigEnv {
|
|
|
53
53
|
/**
|
|
54
54
|
* SASL PLAIN accounts. Newline-delimited `username:password` pairs
|
|
55
55
|
* (same convention as the CF adapter's `SASL_ACCOUNTS`). Parsed into
|
|
56
|
-
* the `saslAccounts` config field so
|
|
57
|
-
*
|
|
56
|
+
* the `saslAccounts` config field so the boot path can seed the
|
|
57
|
+
* `ServicesStore` via `registerNick`.
|
|
58
58
|
*/
|
|
59
59
|
SASL_ACCOUNTS?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Comma-separated allowlist of web origins permitted to open
|
|
62
|
+
* WebSocket upgrades (Cross-Site WebSocket Hijacking defence —
|
|
63
|
+
* browsers send `Origin` on every cross-origin upgrade; WebSocket
|
|
64
|
+
* upgrades do not otherwise enforce same-origin, and API Gateway
|
|
65
|
+
* WebSocket has no built-in Origin check). Mirrors the CF Worker's
|
|
66
|
+
* `WEB_ORIGINS` env.
|
|
67
|
+
*
|
|
68
|
+
* Only read by the `$connect` handler at
|
|
69
|
+
* {@link buildDepsFromEnv} — it is NOT threaded through the shared
|
|
70
|
+
* `ServerConfig` schema (it is an adapter-level operational concern,
|
|
71
|
+
* not a server-config knob). When unset/empty, the defence is
|
|
72
|
+
* disabled and every Origin proceeds (the default — opt-in).
|
|
73
|
+
* Non-browser clients (curl, WeeChat, the `tcp-ws-forwarder`) never
|
|
74
|
+
* send `Origin` and always pass through regardless.
|
|
75
|
+
*/
|
|
76
|
+
WEB_ORIGINS?: string;
|
|
60
77
|
}
|
|
61
78
|
|
|
62
79
|
/**
|
|
@@ -198,6 +198,7 @@ export class DynamoServicesStore extends PersistentServicesStore {
|
|
|
198
198
|
email: row.email,
|
|
199
199
|
createdAt: row.createdAt,
|
|
200
200
|
enforce: row.enforce,
|
|
201
|
+
certSubjects: row.certSubjects ?? [],
|
|
201
202
|
algorithm: row.credential.algorithm,
|
|
202
203
|
salt: row.credential.salt,
|
|
203
204
|
hash: row.credential.hash,
|
|
@@ -536,12 +537,18 @@ function coerceNick(item: Record<string, unknown>): ServicesNickRow | undefined
|
|
|
536
537
|
) {
|
|
537
538
|
return undefined;
|
|
538
539
|
}
|
|
540
|
+
// certSubjects is additive — older rows written before the column existed
|
|
541
|
+
// load as []. Accepts a JSON-array or a string[]; anything else → [].
|
|
542
|
+
const certSubjects = Array.isArray(item.certSubjects)
|
|
543
|
+
? item.certSubjects.filter((s): s is string => typeof s === 'string')
|
|
544
|
+
: [];
|
|
539
545
|
return {
|
|
540
546
|
nick: item.nick,
|
|
541
547
|
account: item.account,
|
|
542
548
|
email: item.email,
|
|
543
549
|
createdAt: item.createdAt,
|
|
544
550
|
enforce: item.enforce as NickEnforcePolicy,
|
|
551
|
+
certSubjects,
|
|
545
552
|
credential: {
|
|
546
553
|
account: item.account,
|
|
547
554
|
algorithm: 'scrypt',
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from '@serverless-ircd/irc-core';
|
|
25
25
|
import { type AdmissionOutcome, decideConnectAdmission } from '../admission.js';
|
|
26
26
|
import { decrementConnectionCount, incrementConnectionCount } from '../connection-counter.js';
|
|
27
|
+
import { decideConnectOrigin } from '../origin-allowlist.js';
|
|
27
28
|
import { marshalConnection } from '../serialize.js';
|
|
28
29
|
import type { TablesConfig } from '../tables.js';
|
|
29
30
|
import { createInitialConnectionState } from './state.js';
|
|
@@ -44,6 +45,22 @@ export interface ConnectParams {
|
|
|
44
45
|
* none match or the header is absent).
|
|
45
46
|
*/
|
|
46
47
|
secWebSocketProtocol?: string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Raw `Origin` header from `event.headers.Origin` (case-insensitive).
|
|
50
|
+
* When {@link ConnectParams.webOrigins} is non-empty and this is a
|
|
51
|
+
* browser-sent origin that is not on the allowlist, the upgrade is
|
|
52
|
+
* rejected with `403` (Cross-Site WebSocket Hijacking defence —
|
|
53
|
+
* API Gateway has no built-in Origin check). `null` / `undefined`
|
|
54
|
+
* (non-browser clients: curl, WeeChat, the `tcp-ws-forwarder`)
|
|
55
|
+
* always proceed; the defence is opt-in (only fires when the
|
|
56
|
+
* allowlist is non-empty).
|
|
57
|
+
*/
|
|
58
|
+
originHeader?: string | null | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Parsed `WEB_ORIGINS` allowlist. Empty set (or `undefined`) disables
|
|
61
|
+
* the Origin check (the default — defence is opt-in).
|
|
62
|
+
*/
|
|
63
|
+
webOrigins?: Set<string> | undefined;
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
/**
|
|
@@ -83,6 +100,15 @@ export async function handleConnect(params: ConnectParams): Promise<ConnectOutco
|
|
|
83
100
|
const now = params.now ?? Date.now();
|
|
84
101
|
const maxClients = params.serverConfig.maxClients;
|
|
85
102
|
|
|
103
|
+
// CSWSH defence — Origin allowlist. Runs before the admission counter
|
|
104
|
+
// so a hostile-origin flood cannot consume `maxClients` slots. Only
|
|
105
|
+
// fires when `WEB_ORIGINS` is set (defence is opt-in); a missing
|
|
106
|
+
// `Origin` header (non-browser clients) always proceeds.
|
|
107
|
+
const originDecision = decideConnectOrigin(params.originHeader, params.webOrigins ?? new Set());
|
|
108
|
+
if (originDecision === 'deny') {
|
|
109
|
+
return { admitted: false, statusCode: 403, reason: 'origin not allowed' };
|
|
110
|
+
}
|
|
111
|
+
|
|
86
112
|
// Reserve a slot atomically. The returned value includes this reservation,
|
|
87
113
|
// so the pre-existing count (`total`, as expected by the policy) is one
|
|
88
114
|
// less than the post-increment value.
|
|
@@ -12,7 +12,6 @@ import type { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanageme
|
|
|
12
12
|
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
13
13
|
import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
|
|
14
14
|
import {
|
|
15
|
-
type AccountStore,
|
|
16
15
|
type ChannelState,
|
|
17
16
|
type Clock,
|
|
18
17
|
type ConnectionState,
|
|
@@ -63,19 +62,14 @@ export interface DefaultParams {
|
|
|
63
62
|
* behaviour of callers that have not opted in.
|
|
64
63
|
*/
|
|
65
64
|
messages?: MessageStore;
|
|
66
|
-
/**
|
|
67
|
-
* SASL account verification source. When omitted the actor runs with
|
|
68
|
-
* `ctx.accounts` undefined so `AUTHENTICATE PLAIN` fails with `904`
|
|
69
|
-
* — preserves the behaviour of callers that have not opted in.
|
|
70
|
-
*/
|
|
71
|
-
accounts?: AccountStore;
|
|
72
65
|
/**
|
|
73
66
|
* Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
|
|
74
|
-
* OperServ + read-marker
|
|
75
|
-
* `ctx.services` undefined so
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
67
|
+
* OperServ + read-marker + the unified SASL/PASS credential home). When
|
|
68
|
+
* omitted the actor runs with `ctx.services` undefined so
|
|
69
|
+
* `PRIVMSG NickServ` / ChanServ routing is disabled and every SASL/PASS
|
|
70
|
+
* credential verify fails — preserves the behaviour of callers that
|
|
71
|
+
* have not opted in. On success the handler flushes pending
|
|
72
|
+
* write-behind ops after the actor run.
|
|
79
73
|
*/
|
|
80
74
|
services?: DynamoServicesStore;
|
|
81
75
|
/**
|
|
@@ -128,113 +122,144 @@ export interface DefaultParams {
|
|
|
128
122
|
export async function handleDefault(params: DefaultParams): Promise<{ statusCode: number }> {
|
|
129
123
|
const clock = params.clock ?? SystemClock;
|
|
130
124
|
const ids = params.ids ?? new UuidIdFactory();
|
|
131
|
-
// Load persisted state. Strongly consistent: $connect writes this row and
|
|
132
|
-
// $default reads it on the very next frame — an eventually-consistent Get
|
|
133
|
-
// can miss the just-written row (DynamoDB replication lag) and silently
|
|
134
|
-
// drop the client's first frame as a 410. Strong consistency closes that
|
|
135
|
-
// read-after-write window.
|
|
136
|
-
const result = await params.dynamo.send(
|
|
137
|
-
new GetCommand({
|
|
138
|
-
TableName: params.tables.Connections,
|
|
139
|
-
Key: { connectionId: params.connectionId },
|
|
140
|
-
ConsistentRead: true,
|
|
141
|
-
}),
|
|
142
|
-
);
|
|
143
|
-
if (result.Item === undefined) {
|
|
144
|
-
// Previously this branch returned silently, making a missing-row
|
|
145
|
-
// $default invisible to operators (client sees nothing, logs show
|
|
146
|
-
// nothing). Warn so the 410 path is diagnosable.
|
|
147
|
-
console.warn(
|
|
148
|
-
`[irc-handler] $default connId=${params.connectionId} wsMode=? row=missing -> 410`,
|
|
149
|
-
);
|
|
150
|
-
return { statusCode: 410 };
|
|
151
|
-
}
|
|
152
|
-
const persisted = result.Item as unknown as MarshalledConnection;
|
|
153
|
-
const state = unmarshalConnection(persisted);
|
|
154
|
-
|
|
155
|
-
// Recover the IRCv3 WebSocket frame mode persisted at `$connect`.
|
|
156
|
-
// Absent = legacy (no subprotocol negotiated). Drives both the inbound
|
|
157
|
-
// line-framing transport (spec: one IRC message per frame, never split)
|
|
158
|
-
// and the outbound delivery shape (spec: one postToConnection per line).
|
|
159
|
-
const wsMode: WsFrameMode = persisted.wsMode ?? 'legacy';
|
|
160
|
-
|
|
161
|
-
// Mark this frame's activity.
|
|
162
|
-
state.lastSeen = clock.now();
|
|
163
125
|
|
|
164
|
-
//
|
|
126
|
+
// Optimistic-concurrency retry loop. API Gateway dispatches a client's
|
|
127
|
+
// rapid frames (CAP END, NICK, USER; or two PRIVMSG) as CONCURRENT
|
|
128
|
+
// Lambda invocations that each read-modify-write the same Connections
|
|
129
|
+
// row. Each attempt:
|
|
130
|
+
// 1. Strongly-consistent read (closes the $connect→$default window).
|
|
131
|
+
// 2. Run the actor over the frame against the fresh state.
|
|
132
|
+
// 3. Conditional write requiring `revision = :expected`; on conflict
|
|
133
|
+
// (another invocation wrote first) re-read and re-run.
|
|
134
|
+
// Membership writes (TransactWriteItems + ADD/DELETE on a String Set)
|
|
135
|
+
// are idempotent by design, so a retried frame never doubles roster
|
|
136
|
+
// state. NOTE: peer `postToConnection` calls issued inside the actor
|
|
137
|
+
// run MAY double-deliver on the rare retry; registration (the deadlock
|
|
138
|
+
// this fixes) has no peer sends, and a duplicated PRIVMSG on a rare
|
|
139
|
+
// retry is strictly better than the total state-loss this loop fixes.
|
|
140
|
+
// Full peer-send buffering is a documented follow-up.
|
|
141
|
+
const MAX_RETRIES = 8;
|
|
165
142
|
const outbound: string[] = [];
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
// management API in production; in tests we collect for assertions.
|
|
170
|
-
for (const l of lines) outbound.push(l.text);
|
|
171
|
-
},
|
|
172
|
-
disconnect: () => {
|
|
173
|
-
// No-op: a Lambda `$default` invocation cannot close its own APIGW
|
|
174
|
-
// socket. The canonical teardown is the `$disconnect` route (which
|
|
175
|
-
// fires when APIGW observes the close) plus the sweeper/ping-checker
|
|
176
|
-
// for connections that vanish without one. The actor's state
|
|
177
|
-
// mutations (nick release, roster deltas) are persisted below before
|
|
178
|
-
// this invocation returns, so the eventual `$disconnect` completes
|
|
179
|
-
// the fanout. See docs/AWS-Deployment.md §14.1.
|
|
180
|
-
},
|
|
181
|
-
snapshot: () => state,
|
|
182
|
-
};
|
|
183
|
-
const runtime = new AwsRuntime({
|
|
184
|
-
dynamo: params.dynamo,
|
|
185
|
-
tables: params.tables,
|
|
186
|
-
connId: params.connectionId,
|
|
187
|
-
handlers,
|
|
188
|
-
managementApi: params.managementApi,
|
|
189
|
-
clock,
|
|
190
|
-
...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
const channelAccess = new LambdaChannelAccess(runtime, clock.now());
|
|
143
|
+
// Captured from the committed attempt so the post-loop outbound flush
|
|
144
|
+
// knows the connection's frame mode (spec vs legacy line framing).
|
|
145
|
+
let wsModeHolder: WsFrameMode = 'legacy';
|
|
194
146
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
147
|
+
/** Builds a fresh actor for `state` and runs this invocation's frame. */
|
|
148
|
+
const runFrame = async (state: ConnectionState, wsMode: WsFrameMode): Promise<void> => {
|
|
149
|
+
const handlers: AwsRuntimeHandlers = {
|
|
150
|
+
send: (lines) => {
|
|
151
|
+
// Self-send: APIGW echoes back to the caller's open socket via the
|
|
152
|
+
// management API in production; in tests we collect for assertions.
|
|
153
|
+
for (const l of lines) outbound.push(l.text);
|
|
154
|
+
},
|
|
155
|
+
disconnect: () => {
|
|
156
|
+
// No-op: a Lambda `$default` invocation cannot close its own APIGW
|
|
157
|
+
// socket. The canonical teardown is the `$disconnect` route (which
|
|
158
|
+
// fires when APIGW observes the close) plus the sweeper/ping-checker
|
|
159
|
+
// for connections that vanish without one. The actor's state
|
|
160
|
+
// mutations (nick release, roster deltas) are persisted below before
|
|
161
|
+
// this invocation returns, so the eventual `$disconnect` completes
|
|
162
|
+
// the fanout. See docs/AWS-Deployment.md §14.1.
|
|
163
|
+
},
|
|
164
|
+
snapshot: () => state,
|
|
165
|
+
};
|
|
166
|
+
const runtime = new AwsRuntime({
|
|
210
167
|
dynamo: params.dynamo,
|
|
211
168
|
tables: params.tables,
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
169
|
+
connId: params.connectionId,
|
|
170
|
+
handlers,
|
|
171
|
+
managementApi: params.managementApi,
|
|
172
|
+
clock,
|
|
173
|
+
...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
|
|
174
|
+
});
|
|
175
|
+
const channelAccess = new LambdaChannelAccess(runtime, clock.now());
|
|
176
|
+
const actor = new ConnectionActor({
|
|
177
|
+
state,
|
|
178
|
+
runtime,
|
|
179
|
+
channels: channelAccess,
|
|
180
|
+
serverConfig: params.serverConfig,
|
|
181
|
+
configSource: 'Secrets Manager',
|
|
182
|
+
clock,
|
|
183
|
+
ids,
|
|
184
|
+
motd: params.motd,
|
|
185
|
+
transport: { feed: (chunk) => frameToLines(chunk, wsMode) },
|
|
186
|
+
// AwsStats Scans Connections + ChannelMeta for LUSERS/STATS.
|
|
187
|
+
// `uptimeStartedAt` is captured at the Lambda's cold start so `STATS u`
|
|
188
|
+
// reports per-isolate uptime; a deployment-wide uptime would require a
|
|
189
|
+
// persistent record (future work).
|
|
190
|
+
stats: new AwsStats({
|
|
191
|
+
dynamo: params.dynamo,
|
|
192
|
+
tables: params.tables,
|
|
193
|
+
uptimeStartedAt: LAMBDA_STARTUP_AT,
|
|
194
|
+
}),
|
|
195
|
+
...(params.messages !== undefined ? { messages: params.messages } : {}),
|
|
196
|
+
...(params.services !== undefined ? { services: params.services } : {}),
|
|
197
|
+
...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
|
|
198
|
+
...(params.history !== undefined ? { history: params.history } : {}),
|
|
199
|
+
// API Gateway terminates TLS before the Lambda is invoked, so every
|
|
200
|
+
// WebSocket connection is secure → user mode `S`.
|
|
201
|
+
secure: true,
|
|
202
|
+
});
|
|
225
203
|
await actor.receiveTextFrame(params.body);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
await params.
|
|
231
|
-
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
for (let attempt = 0; ; attempt++) {
|
|
207
|
+
// 1. Strongly-consistent read.
|
|
208
|
+
const result = await params.dynamo.send(
|
|
209
|
+
new GetCommand({
|
|
210
|
+
TableName: params.tables.Connections,
|
|
211
|
+
Key: { connectionId: params.connectionId },
|
|
212
|
+
ConsistentRead: true,
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
if (result.Item === undefined) {
|
|
216
|
+
// Row gone (client reconnected elsewhere, swept, or stale connId).
|
|
217
|
+
// Not retriable: surface the 410 so the client reconnects.
|
|
218
|
+
console.warn(
|
|
219
|
+
`[irc-handler] $default connId=${params.connectionId} wsMode=? row=missing -> 410`,
|
|
220
|
+
);
|
|
221
|
+
return { statusCode: 410 };
|
|
222
|
+
}
|
|
223
|
+
const persisted = result.Item as unknown as MarshalledConnection;
|
|
224
|
+
const state = unmarshalConnection(persisted);
|
|
225
|
+
const expectedRevision = persisted.revision ?? 0;
|
|
226
|
+
const wsMode: WsFrameMode = persisted.wsMode ?? 'legacy';
|
|
227
|
+
wsModeHolder = wsMode;
|
|
228
|
+
|
|
229
|
+
// 2. Mark activity + run the actor. Self-sends reset per attempt.
|
|
230
|
+
state.lastSeen = clock.now();
|
|
231
|
+
outbound.length = 0;
|
|
232
|
+
try {
|
|
233
|
+
await runFrame(state, wsMode);
|
|
234
|
+
} catch (err: unknown) {
|
|
235
|
+
console.error('[irc-handler] actor.receiveTextFrame failed', err);
|
|
236
|
+
// Best-effort persist of partial state (swallow a conflict here —
|
|
237
|
+
// the actor already failed, no point retrying).
|
|
238
|
+
await persistState(params.dynamo, params.tables, state, clock.now(), expectedRevision).catch(
|
|
239
|
+
() => {},
|
|
240
|
+
);
|
|
241
|
+
await params.services?.flush();
|
|
242
|
+
return { statusCode: 500 };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 3. Conditional persist.
|
|
246
|
+
try {
|
|
247
|
+
await persistState(params.dynamo, params.tables, state, clock.now(), expectedRevision);
|
|
248
|
+
break; // committed — exit the retry loop.
|
|
249
|
+
} catch (err: unknown) {
|
|
250
|
+
if (isConditionalCheckFailed(err) && attempt < MAX_RETRIES) {
|
|
251
|
+
// Another invocation wrote first; re-read + re-run against fresh state.
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
// Exhausted retries or an unrelated DynamoDB error.
|
|
255
|
+
console.error('[irc-handler] persist failed (retries exhausted or unrecoverable)', err);
|
|
256
|
+
await params.services?.flush();
|
|
257
|
+
return { statusCode: 500 };
|
|
258
|
+
}
|
|
232
259
|
}
|
|
233
260
|
|
|
234
|
-
// Persist
|
|
235
|
-
|
|
236
|
-
// Drain any services write-behind ops (NickServ/ChanServ/...) so a
|
|
237
|
-
// Lambda freeze / evict does not lose state.
|
|
261
|
+
// Persist committed. Drain any services write-behind ops (NickServ/ChanServ/...)
|
|
262
|
+
// so a Lambda freeze / evict does not lose state.
|
|
238
263
|
await params.services?.flush();
|
|
239
264
|
|
|
240
265
|
// In production, deliver outbound bytes back to the caller via APIGW.
|
|
@@ -243,7 +268,7 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
243
268
|
// receive every line CRLF-joined in a single message.
|
|
244
269
|
if (params.managementApi !== null && outbound.length > 0) {
|
|
245
270
|
try {
|
|
246
|
-
await postOutbound(params.managementApi, params.connectionId, outbound,
|
|
271
|
+
await postOutbound(params.managementApi, params.connectionId, outbound, wsModeHolder);
|
|
247
272
|
} catch (err) {
|
|
248
273
|
// Surface the failure (gone connection, stale MANAGEMENT_URL, …)
|
|
249
274
|
// so it shows in CloudWatch instead of vanishing. The handler still
|
|
@@ -280,28 +305,34 @@ async function postOutbound(
|
|
|
280
305
|
/**
|
|
281
306
|
* Writes the actor's mutated `state` back to the Connections row.
|
|
282
307
|
*
|
|
283
|
-
* Uses
|
|
284
|
-
* `joinedChannels
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
308
|
+
* Uses a conditional `UpdateCommand` that SETs every actor-owned field
|
|
309
|
+
* EXCEPT `joinedChannels`, bumps the `revision` counter, and requires
|
|
310
|
+
* the row's `revision` to still equal `expectedRevision` (the value read
|
|
311
|
+
* at the start of this invocation). A `ConditionalCheckFailedException`
|
|
312
|
+
* means another concurrent `$default` for the same connection wrote in
|
|
313
|
+
* between; {@link handleDefault} catches it and retries the whole
|
|
314
|
+
* read → run → persist cycle against the fresh row. The membership
|
|
315
|
+
* transaction (see `AwsRuntime`) owns `joinedChannels` atomically and
|
|
316
|
+
* is idempotent (`ADD`/`DELETE` on a String Set), so a retried frame
|
|
317
|
+
* never doubles roster state. Optional fields (nick, user, …) are SET
|
|
318
|
+
* when present and REMOVE'd when absent so a cleared value does not
|
|
319
|
+
* linger from a previous frame.
|
|
290
320
|
*/
|
|
291
321
|
async function persistState(
|
|
292
322
|
dynamo: DynamoDBDocumentClient,
|
|
293
323
|
tables: TablesConfig,
|
|
294
|
-
_connId: string,
|
|
295
324
|
state: ConnectionState,
|
|
296
325
|
now: number,
|
|
326
|
+
expectedRevision: number,
|
|
297
327
|
): Promise<void> {
|
|
298
|
-
const expr = buildPersistUpdate(state, now);
|
|
328
|
+
const expr = buildPersistUpdate(state, now, expectedRevision);
|
|
299
329
|
const hasNames = Object.keys(expr.expressionAttributeNames).length > 0;
|
|
300
330
|
await dynamo.send(
|
|
301
331
|
new UpdateCommand({
|
|
302
332
|
TableName: tables.Connections,
|
|
303
333
|
Key: { connectionId: state.id },
|
|
304
334
|
UpdateExpression: expr.updateExpression,
|
|
335
|
+
ConditionExpression: expr.conditionExpression,
|
|
305
336
|
ExpressionAttributeValues: expr.expressionAttributeValues,
|
|
306
337
|
...(hasNames ? { ExpressionAttributeNames: expr.expressionAttributeNames } : {}),
|
|
307
338
|
}),
|
|
@@ -326,12 +357,27 @@ const OPTIONAL_FIELDS = [
|
|
|
326
357
|
* the required fields; SETs optional fields that are present and
|
|
327
358
|
* REMOVEs those that are absent. `joinedChannels` is intentionally
|
|
328
359
|
* excluded — owned by the membership transaction.
|
|
360
|
+
*
|
|
361
|
+
* **Optimistic concurrency:** the expression also `ADD`s `1` to the
|
|
362
|
+
* `revision` counter and attaches a `ConditionExpression` requiring
|
|
363
|
+
* `revision = :expectedRev` (or `attribute_not_exists(#rev)` for rows
|
|
364
|
+
* written before the field shipped). {@link handleDefault} captures the
|
|
365
|
+
* row's revision at read time and passes it as `expectedRevision`; a
|
|
366
|
+
* `ConditionalCheckFailedException` means another concurrent invocation
|
|
367
|
+
* for the same connection wrote in between → the caller re-reads and
|
|
368
|
+
* re-runs the frame (see {@link handleDefault}'s retry loop). Without
|
|
369
|
+
* this guard, two concurrent `$default` invocations (API Gateway
|
|
370
|
+
* dispatches a client's rapid frames as overlapping Lambdas) clobber
|
|
371
|
+
* each other's state — the registration deadlock where `CAP END`'s
|
|
372
|
+
* `capNegotiating=false` was overwritten by `NICK`/`USER`'s stale read.
|
|
329
373
|
*/
|
|
330
|
-
function buildPersistUpdate(
|
|
374
|
+
export function buildPersistUpdate(
|
|
331
375
|
state: ConnectionState,
|
|
332
376
|
now: number,
|
|
377
|
+
expectedRevision: number,
|
|
333
378
|
): {
|
|
334
379
|
updateExpression: string;
|
|
380
|
+
conditionExpression: string;
|
|
335
381
|
expressionAttributeNames: Record<string, string>;
|
|
336
382
|
expressionAttributeValues: Record<string, unknown>;
|
|
337
383
|
} {
|
|
@@ -343,6 +389,10 @@ function buildPersistUpdate(
|
|
|
343
389
|
':ls': state.lastSeen,
|
|
344
390
|
':is': now,
|
|
345
391
|
':v': CONNECTION_VERSION,
|
|
392
|
+
// Optimistic-concurrency: increment the revision counter, gated by
|
|
393
|
+
// a condition that the row is still at the revision we read.
|
|
394
|
+
':revInc': 1,
|
|
395
|
+
':expectedRev': expectedRevision,
|
|
346
396
|
};
|
|
347
397
|
const setNames = [
|
|
348
398
|
'registration = :reg',
|
|
@@ -354,7 +404,7 @@ function buildPersistUpdate(
|
|
|
354
404
|
'version = :v',
|
|
355
405
|
];
|
|
356
406
|
const removeNames: string[] = [];
|
|
357
|
-
const nameMap: Record<string, string> = {};
|
|
407
|
+
const nameMap: Record<string, string> = { '#rev': 'revision' };
|
|
358
408
|
|
|
359
409
|
for (const field of OPTIONAL_FIELDS) {
|
|
360
410
|
const placeholder = `:${field}`;
|
|
@@ -373,13 +423,30 @@ function buildPersistUpdate(
|
|
|
373
423
|
if (removeNames.length > 0) {
|
|
374
424
|
parts.push(`REMOVE ${removeNames.join(', ')}`);
|
|
375
425
|
}
|
|
426
|
+
// Bump the optimistic-concurrency counter atomically with the state write.
|
|
427
|
+
parts.push('ADD #rev :revInc');
|
|
376
428
|
return {
|
|
377
429
|
updateExpression: parts.join(' '),
|
|
430
|
+
// `attribute_not_exists(#rev)` lets the first write after deploy land on
|
|
431
|
+
// pre-existing rows that predate the column (treats them as revision 0).
|
|
432
|
+
conditionExpression: 'attribute_not_exists(#rev) OR #rev = :expectedRev',
|
|
378
433
|
expressionAttributeNames: nameMap,
|
|
379
434
|
expressionAttributeValues: values,
|
|
380
435
|
};
|
|
381
436
|
}
|
|
382
437
|
|
|
438
|
+
/**
|
|
439
|
+
* Returns true when a DynamoDB error is a conditional-check failure
|
|
440
|
+
* (the row's `revision` no longer matches the value the invocation
|
|
441
|
+
* read). Such failures are retried by {@link handleDefault}'s
|
|
442
|
+
* optimistic-concurrency loop after re-reading and re-running the frame.
|
|
443
|
+
*/
|
|
444
|
+
export function isConditionalCheckFailed(err: unknown): boolean {
|
|
445
|
+
if (err === null || typeof err !== 'object') return false;
|
|
446
|
+
const name = (err as { name?: string }).name;
|
|
447
|
+
return name === 'ConditionalCheckFailedException';
|
|
448
|
+
}
|
|
449
|
+
|
|
383
450
|
/**
|
|
384
451
|
* `ActorChannelAccess` backed by the per-invocation {@link AwsRuntime}.
|
|
385
452
|
*
|