serverless-ircd 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +28 -19
  3. package/apps/cf-worker/package.json +1 -1
  4. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  5. package/apps/cf-worker/src/worker.ts +8 -2
  6. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  7. package/apps/cf-worker/wrangler.test.toml +5 -1
  8. package/apps/cf-worker/wrangler.toml +41 -17
  9. package/apps/local-cli/package.json +1 -1
  10. package/apps/local-cli/src/config-loader.ts +1 -0
  11. package/apps/local-cli/src/main.ts +1 -1
  12. package/apps/local-cli/src/server.ts +28 -2
  13. package/apps/local-cli/tests/e2e.test.ts +52 -0
  14. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  15. package/docs/ADR-002-location-of-authority.md +82 -0
  16. package/docs/ADR-003-durable-object-sharding.md +93 -0
  17. package/docs/ADR-004-dynamodb-schema.md +96 -0
  18. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  19. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  20. package/docs/ADR-007-deterministic-ports.md +82 -0
  21. package/docs/ADR-008-monorepo-tooling.md +60 -0
  22. package/docs/AWS-Adapter-Architecture.md +6 -4
  23. package/docs/AWS-Deployment.md +86 -7
  24. package/docs/Cloudflare-Deployment-Guide.md +5 -5
  25. package/docs/Home.md +11 -1
  26. package/docs/Release-Process.md +9 -6
  27. package/package.json +14 -15
  28. package/packages/aws-adapter/package.json +1 -1
  29. package/packages/aws-adapter/src/account-store.ts +121 -0
  30. package/packages/aws-adapter/src/aws-runtime.ts +118 -30
  31. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  32. package/packages/aws-adapter/src/config-loader.ts +30 -0
  33. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  34. package/packages/aws-adapter/src/handlers/default.ts +8 -0
  35. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  36. package/packages/aws-adapter/src/handlers/index.ts +51 -6
  37. package/packages/aws-adapter/src/index.ts +7 -0
  38. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  39. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  40. package/packages/aws-adapter/tests/config-loader.test.ts +55 -0
  41. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  42. package/packages/aws-adapter/tests/handlers.test.ts +46 -0
  43. package/packages/cf-adapter/package.json +1 -1
  44. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  45. package/packages/cf-adapter/src/channel-do.ts +40 -1
  46. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  47. package/packages/cf-adapter/src/config-loader.ts +29 -0
  48. package/packages/cf-adapter/src/connection-do.ts +85 -0
  49. package/packages/cf-adapter/src/env.ts +27 -1
  50. package/packages/cf-adapter/src/index.ts +2 -1
  51. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  52. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  53. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  54. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  55. package/packages/cf-adapter/wrangler.test.toml +10 -1
  56. package/packages/in-memory-runtime/package.json +1 -1
  57. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  58. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  59. package/packages/irc-core/package.json +3 -2
  60. package/packages/irc-core/src/case-fold.ts +64 -0
  61. package/packages/irc-core/src/commands/index.ts +2 -0
  62. package/packages/irc-core/src/commands/invite.ts +6 -9
  63. package/packages/irc-core/src/commands/isupport.ts +1 -1
  64. package/packages/irc-core/src/commands/join.ts +4 -3
  65. package/packages/irc-core/src/commands/kick.ts +4 -11
  66. package/packages/irc-core/src/commands/list.ts +5 -4
  67. package/packages/irc-core/src/commands/mode.ts +5 -9
  68. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  69. package/packages/irc-core/src/commands/motd.ts +6 -110
  70. package/packages/irc-core/src/commands/oper.ts +151 -0
  71. package/packages/irc-core/src/commands/registration.ts +8 -7
  72. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  73. package/packages/irc-core/src/config.ts +26 -3
  74. package/packages/irc-core/src/index.ts +2 -0
  75. package/packages/irc-core/src/ports.ts +68 -4
  76. package/packages/irc-core/src/types.ts +22 -0
  77. package/packages/irc-core/tests/account-store.test.ts +88 -0
  78. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  79. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  80. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  81. package/packages/irc-core/tests/commands/registration.test.ts +106 -14
  82. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  83. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  84. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  85. package/packages/irc-core/tests/config.test.ts +75 -0
  86. package/packages/irc-server/package.json +1 -1
  87. package/packages/irc-server/src/actor.ts +24 -0
  88. package/packages/irc-server/src/routing.ts +9 -0
  89. package/packages/irc-server/tests/actor.test.ts +220 -1
  90. package/packages/irc-server/tests/routing.test.ts +3 -0
  91. package/packages/irc-test-support/package.json +1 -1
  92. package/packages/irc-test-support/src/in-memory-harness.ts +5 -4
  93. package/pnpm-workspace.yaml +1 -0
  94. package/tools/seed-aws-accounts.ts +80 -0
  95. package/AGENTS.md +0 -5
  96. package/MOTD.txt +0 -3
  97. package/PLAN-FIXES.md +0 -358
  98. package/PLAN.md +0 -420
  99. package/dashboards/cloudwatch-irc.json +0 -139
  100. package/progress.md +0 -107
  101. package/tickets.md +0 -2485
  102. package/webircgateway/LICENSE +0 -201
  103. package/webircgateway/Makefile +0 -44
  104. package/webircgateway/README.md +0 -134
  105. package/webircgateway/config.conf.example +0 -135
  106. package/webircgateway/go.mod +0 -16
  107. package/webircgateway/go.sum +0 -89
  108. package/webircgateway/main.go +0 -118
  109. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  110. package/webircgateway/pkg/identd/identd.go +0 -86
  111. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  112. package/webircgateway/pkg/irc/isupport.go +0 -56
  113. package/webircgateway/pkg/irc/message.go +0 -217
  114. package/webircgateway/pkg/irc/state.go +0 -79
  115. package/webircgateway/pkg/proxy/proxy.go +0 -129
  116. package/webircgateway/pkg/proxy/server.go +0 -237
  117. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  118. package/webircgateway/pkg/webircgateway/client.go +0 -741
  119. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  120. package/webircgateway/pkg/webircgateway/config.go +0 -385
  121. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  122. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  123. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  124. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  125. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  126. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  127. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  128. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  129. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  130. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  131. package/webircgateway/plugins/example/plugin.go +0 -11
  132. package/webircgateway/plugins/stats/plugin.go +0 -52
  133. package/webircgateway/staticcheck.conf +0 -1
  134. package/webircgateway/webircgateway.svg +0 -3
package/docs/Home.md CHANGED
@@ -1 +1,11 @@
1
- Welcome to the Wiki.
1
+ Welcome to the Wiki.
2
+
3
+ ## Pages
4
+
5
+ - [AWS Adapter Architecture](AWS-Adapter-Architecture)
6
+ - [AWS Deployment Guide](AWS-Deployment)
7
+ - [Cloudflare Deployment Guide](Cloudflare-Deployment-Guide)
8
+ - [Observability](Observability)
9
+ - [IRCv3 WebSocket Extension Plan](PlanIRCv3Websocket)
10
+ - [Web Client Plan](PlanWebClient)
11
+ - [Release Process](Release-Process)
@@ -110,11 +110,15 @@ pnpm typecheck # tsc --noEmit across workspace
110
110
  pnpm test # full suite
111
111
  pnpm build # every package produces dist/
112
112
 
113
- # 4. Coverage gate.
114
- # NOTE: the 90% coverage gate (TICKET-048) is not yet wired in CI.
115
- # Until it lands, manually inspect packages/*/coverage/ for regressions
116
- # vs. the previous release. Any drop > 2 percentage points blocks the
117
- # release unless explicitly justified in CHANGELOG.md.
113
+ # 4. Coverage gate (enforced in CI per package).
114
+ # The per-package coverage gate (TICKET-048) runs in CI via
115
+ # `pnpm coverage`; Vitest fails the build on any threshold regression.
116
+ # Thresholds: 100% (lines/functions/branches/statements) on
117
+ # irc-core / irc-server / in-memory-runtime, and ≥90% on every
118
+ # other package (cf-adapter / local-cli / tcp-ws-forwarder). See the
119
+ # "Coverage gate" step in .github/workflows/ci.yml. CI must already
120
+ # be green (step 2), so locally re-run `pnpm coverage` as a
121
+ # belt-and-braces check before tagging.
118
122
 
119
123
  # 5. Staging smoke e2e passes against the deployed tip-of-main.
120
124
  node apps/cf-worker/scripts/smoke.mjs \
@@ -135,7 +139,6 @@ checklist — do not tag over a broken tip.
135
139
  - Flood control (TICKET-028).
136
140
  - Server password / rate limits / cloaking (TICKET-046).
137
141
  - Config loader (TICKET-047).
138
- - CI coverage gate ≥90% (TICKET-048).
139
142
 
140
143
  These belong in a later 0.x release. Their absence is **not** a
141
144
  blocker and should be called out under "Known limitations" in the
package/package.json CHANGED
@@ -1,13 +1,22 @@
1
1
  {
2
2
  "name": "serverless-ircd",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
7
- "packageManager": "pnpm@9.15.9",
8
7
  "engines": {
9
8
  "node": ">=20"
10
9
  },
10
+ "devDependencies": {
11
+ "@biomejs/biome": "^1.9.0",
12
+ "@vitest/coverage-v8": "^4.1.0",
13
+ "fast-check": "^3.19.0",
14
+ "rimraf": "^6.0.0",
15
+ "turbo": "^2.0.0",
16
+ "typescript": "^5.6.0",
17
+ "vite": "^7.3.0",
18
+ "vitest": "^4.1.0"
19
+ },
11
20
  "scripts": {
12
21
  "build": "turbo run build",
13
22
  "typecheck": "turbo run typecheck",
@@ -24,18 +33,8 @@
24
33
  "deploy:cf:staging": "pnpm --filter @serverless-ircd/cf-worker deploy:staging",
25
34
  "deploy:cf:prod": "pnpm --filter @serverless-ircd/cf-worker deploy:prod",
26
35
  "smoke:cf:staging": "pnpm --filter @serverless-ircd/cf-worker smoke:staging",
27
- "deploy:aws:staging": "pnpm --filter @serverless-ircd/aws-stack deploy:staging",
28
- "deploy:aws:prod": "pnpm --filter @serverless-ircd/aws-stack deploy:prod",
36
+ "deploy:aws:staging": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:staging",
37
+ "deploy:aws:prod": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:prod",
29
38
  "smoke:aws:staging": "pnpm --filter @serverless-ircd/aws-stack smoke:staging"
30
- },
31
- "devDependencies": {
32
- "@biomejs/biome": "^1.9.0",
33
- "@vitest/coverage-v8": "^4.1.0",
34
- "fast-check": "^3.19.0",
35
- "rimraf": "^6.0.0",
36
- "turbo": "^2.0.0",
37
- "typescript": "^5.6.0",
38
- "vite": "^7.3.0",
39
- "vitest": "^4.1.0"
40
39
  }
41
- }
40
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-adapter",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "description": "AWS Lambda + DynamoDB adapter: AwsRuntime implementing IrcRuntime + $connect/$disconnect/$default handlers",
6
6
  "license": "BSD-3-Clause",
@@ -0,0 +1,121 @@
1
+ /**
2
+ * SASL account store construction + resolution for the AWS adapter.
3
+ *
4
+ * Centralises `AccountStore` creation so the actor-construction site
5
+ * (`handleDefault`) binds a single helper rather than constructing the
6
+ * store inline. Two sources of credentials are reconciled here:
7
+ *
8
+ * 1. The DynamoDB `Accounts` table (authoritative when populated) —
9
+ * loaded at cold start into a {@link DynamoAccountStore} by
10
+ * {@link resolveAccountStore}. Credentials are stored as scrypt
11
+ * hashes, never plaintext.
12
+ * 2. The parsed server config (`saslAccounts`, sourced from the
13
+ * `SASL_ACCOUNTS` env var) — the legacy/config fallback used only
14
+ * when the table is empty, preserving the config-seed behaviour for
15
+ * deployments that have not migrated to the table.
16
+ *
17
+ * The resolved store is scoped to the Lambda execution context: it
18
+ * persists across warm invocations (via the memoised `HandlerDeps`) but
19
+ * is rebuilt on cold start, picking up account changes made since the
20
+ * last cold start.
21
+ */
22
+
23
+ import { PutCommand } from '@aws-sdk/lib-dynamodb';
24
+ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
25
+ import {
26
+ type AccountStore,
27
+ InMemoryAccountStore,
28
+ type ParsedServerConfig,
29
+ } from '@serverless-ircd/irc-core';
30
+ import {
31
+ type HashedAccountCredential,
32
+ DynamoAccountStore,
33
+ hashAccountCredential,
34
+ loadDynamoAccountStore,
35
+ } from './dynamo-account-store.js';
36
+
37
+ // Re-export so callers can import everything from one module.
38
+ export {
39
+ DynamoAccountStore,
40
+ type HashedAccountCredential,
41
+ hashAccountCredential,
42
+ loadDynamoAccountStore,
43
+ };
44
+
45
+ /**
46
+ * Constructs the config-seeded in-memory account store (the legacy
47
+ * fallback). Returns `undefined` when no accounts are configured so the
48
+ * actor's `ctx.accounts` stays unset (preserving the no-store behaviour:
49
+ * `AUTHENTICATE PLAIN` → `904`).
50
+ *
51
+ * @param serverConfig Parsed config carrying the `saslAccounts` seed list.
52
+ */
53
+ export function bindAccountStore(serverConfig?: ParsedServerConfig): AccountStore | undefined {
54
+ if (serverConfig === undefined || serverConfig.saslAccounts.length === 0) {
55
+ return undefined;
56
+ }
57
+ return new InMemoryAccountStore(serverConfig.saslAccounts);
58
+ }
59
+
60
+ /**
61
+ * Resolves the SASL account store for a deployment, applying the
62
+ * table-then-config precedence.
63
+ *
64
+ * 1. Scans the DynamoDB `Accounts` table (pre-loading every hashed
65
+ * credential into a {@link DynamoAccountStore}). When the table has
66
+ * one or more rows it is authoritative — the table wins and the
67
+ * config seed is ignored.
68
+ * 2. When the table is empty, falls back to {@link bindAccountStore}
69
+ * (the `SASL_ACCOUNTS` env-var seed) so existing deployments that
70
+ * have not migrated to the table keep working unchanged.
71
+ * 3. Returns `undefined` when neither source has accounts (the default)
72
+ * so `ctx.accounts` stays unset and `AUTHENTICATE PLAIN` → `904`.
73
+ *
74
+ * Scan failures (table not found, network error) are swallowed and
75
+ * treated as an empty table, falling through to the config seed.
76
+ *
77
+ * @param docClient DynamoDB client addressed at the deployment's tables.
78
+ * @param tableName Physical name of the `Accounts` table for this env.
79
+ * @param serverConfig Parsed config (the config-seed fallback source).
80
+ */
81
+ export async function resolveAccountStore(
82
+ docClient: DynamoDBDocumentClient,
83
+ tableName: string,
84
+ serverConfig?: ParsedServerConfig,
85
+ ): Promise<AccountStore | undefined> {
86
+ try {
87
+ const store = await loadDynamoAccountStore(docClient, tableName);
88
+ if (store !== undefined) return store;
89
+ } catch {
90
+ // Table might not exist or be unreachable; fall through to config seed.
91
+ }
92
+ return bindAccountStore(serverConfig);
93
+ }
94
+
95
+ /**
96
+ * Writes (or re-seeds) a SASL PLAIN account into the `Accounts` table.
97
+ *
98
+ * The password is hashed via {@link hashAccountCredential} (scrypt,
99
+ * random salt) and stored as a {@link HashedAccountCredential} row —
100
+ * plaintext is never written. A repeat call for the same `username`
101
+ * overwrites the prior row (the PK is `account`), so this is the
102
+ * canonical CRUD/seed entry point for oper and SASL account
103
+ * provisioning.
104
+ *
105
+ * @returns The stored {@link HashedAccountCredential} (for tooling/tests).
106
+ */
107
+ export async function putAccountCredential(
108
+ docClient: DynamoDBDocumentClient,
109
+ tableName: string,
110
+ username: string,
111
+ password: string,
112
+ ): Promise<HashedAccountCredential> {
113
+ const entry = hashAccountCredential(username, password);
114
+ await docClient.send(
115
+ new PutCommand({
116
+ TableName: tableName,
117
+ Item: entry,
118
+ }),
119
+ );
120
+ return entry;
121
+ }
@@ -627,56 +627,144 @@ export class AwsRuntime implements IrcRuntime {
627
627
 
628
628
  /**
629
629
  * Tears down a connection: deletes its Connections row, removes every
630
- * ChannelMembers row owned by it, and releases its nick (if any).
630
+ * ChannelMembers row owned by it, releases its nick (if any), and — when
631
+ * a `managementApi` is supplied — broadcasts a `QUIT` line to every peer
632
+ * that shares a channel with the disconnecting connection.
631
633
  *
632
634
  * Idempotent: every step tolerates a missing row. Safe to call from
633
- * `$disconnect`, from the `send` GoneException path, and from the
634
- * future sweeper Lambda.
635
+ * `$disconnect`, from the `send`/`broadcast` `GoneException` path, and
636
+ * from the sweeper Lambda.
635
637
  *
636
- * `managementApi` is optional because the cleanup path itself does not
637
- * send WebSocket bytes (the socket is, by definition, gone).
638
+ * Recursion safety: the Connections row is deleted *before* the QUIT
639
+ * fanout so a recursive `cleanupConnection` triggered by a gone peer
640
+ * during fanout finds the row already gone and returns immediately —
641
+ * no unbounded recursion.
642
+ *
643
+ * `managementApi` is optional because callers that never need fanout
644
+ * (the sweeper Lambda, or a no-op test) may pass `null`.
638
645
  */
639
646
  export async function cleanupConnection(
640
647
  dynamo: DynamoDBDocumentClient,
641
648
  tables: TablesConfig,
642
649
  connId: ConnId,
643
650
  managementApi: ApiGatewayManagementApi | PostToConnection | null,
651
+ quitMessage = 'Client Quit',
644
652
  ): Promise<void> {
645
- void managementApi; // reserved for a future "broadcast QUIT to peers" step
646
653
  const connRow = await dynamo.send(
647
654
  new GetCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
648
655
  );
649
656
  const item = connRow.Item as unknown as MarshalledConnection | undefined;
650
- if (item !== undefined) {
651
- if (item.nick !== undefined) {
652
- try {
653
- await dynamo.send(
654
- new DeleteCommand({
655
- TableName: tables.Nicks,
656
- Key: { nickLower: item.nick.toLowerCase() },
657
- }),
658
- );
659
- } catch {
660
- // Idempotent — the nick may have been released already.
661
- }
657
+ if (item === undefined) return; // idempotent — already cleaned up
658
+
659
+ // Delete the Connections row FIRST. A recursive cleanup triggered by
660
+ // a gone peer during QUIT fanout reads this same row, finds it gone,
661
+ // and returns — bounding the recursion at one extra hop.
662
+ await dynamo.send(
663
+ new DeleteCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
664
+ );
665
+
666
+ if (item.nick !== undefined) {
667
+ try {
668
+ await dynamo.send(
669
+ new DeleteCommand({
670
+ TableName: tables.Nicks,
671
+ Key: { nickLower: item.nick.toLowerCase() },
672
+ }),
673
+ );
674
+ } catch {
675
+ // Idempotent — the nick may have been released already.
676
+ }
677
+ }
678
+
679
+ // Broadcast QUIT to peers in shared channels *before* removing the
680
+ // disconnecting connection's membership rows — the roster query needs
681
+ // those rows to resolve the fanout targets.
682
+ if (item.nick !== undefined && managementApi !== null) {
683
+ await broadcastQuitToChannels(dynamo, tables, connId, item, managementApi, quitMessage);
684
+ }
685
+
686
+ const joined = item.joinedChannels ?? [];
687
+ for (const chan of joined) {
688
+ try {
689
+ await dynamo.send(
690
+ new DeleteCommand({
691
+ TableName: tables.ChannelMembers,
692
+ Key: { channelName: chan.toLowerCase(), connectionId: connId },
693
+ }),
694
+ );
695
+ } catch {
696
+ // Idempotent — the membership may have been removed already.
662
697
  }
663
- const joined = item.joinedChannels ?? [];
664
- for (const chan of joined) {
698
+ }
699
+ }
700
+
701
+ /**
702
+ * Builds the canonical `:nick!user@host QUIT :<reason>` line for the
703
+ * disconnecting connection and fans it out to every *other* member of
704
+ * each channel the connection had joined. A peer whose APIGW socket is
705
+ * already gone raises `GoneException`, which is caught and routed back
706
+ * through {@link cleanupConnection} for lazy cleanup — a partially
707
+ * torn-down roster never crashes the fanout loop.
708
+ *
709
+ * Shared by both the `$disconnect` route and the `send()`/`broadcast()`
710
+ * `GoneException` cleanup path.
711
+ */
712
+ async function broadcastQuitToChannels(
713
+ dynamo: DynamoDBDocumentClient,
714
+ tables: TablesConfig,
715
+ connId: ConnId,
716
+ item: MarshalledConnection,
717
+ managementApi: ApiGatewayManagementApi | PostToConnection,
718
+ quitMessage: string,
719
+ ): Promise<void> {
720
+ const hostmask = buildHostmask(item);
721
+ if (hostmask === undefined) return; // no nick → nothing to attribute the QUIT to
722
+ const line = `:${hostmask} QUIT :${quitMessage}`;
723
+ const joined = item.joinedChannels ?? [];
724
+ for (const chan of joined) {
725
+ const members = await queryChannelMembersRows(dynamo, tables, chan.toLowerCase());
726
+ for (const member of members) {
727
+ if (member.connectionId === connId) continue; // never echo to self
665
728
  try {
666
- await dynamo.send(
667
- new DeleteCommand({
668
- TableName: tables.ChannelMembers,
669
- Key: { channelName: chan.toLowerCase(), connectionId: connId },
670
- }),
671
- );
672
- } catch {
673
- // Idempotent — the membership may have been removed already.
729
+ await managementApi.postToConnection({ ConnectionId: member.connectionId, Data: line });
730
+ } catch (err: unknown) {
731
+ if (err instanceof GoneException || isGone(err)) {
732
+ await cleanupConnection(dynamo, tables, member.connectionId, managementApi, quitMessage);
733
+ } else {
734
+ throw err;
735
+ }
674
736
  }
675
737
  }
676
738
  }
677
- await dynamo.send(
678
- new DeleteCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
739
+ }
740
+
741
+ /**
742
+ * Builds the `nick!user@host` hostmask from a stored connection row,
743
+ * omitting the absent parts. Mirrors {@link hostmaskOf} in irc-core but
744
+ * operates on the marshalled shape (no full unmarshal needed).
745
+ */
746
+ function buildHostmask(item: MarshalledConnection): string | undefined {
747
+ if (item.nick === undefined) return undefined;
748
+ let out: string = item.nick;
749
+ if (item.user !== undefined) out = `${out}!${item.user}`;
750
+ if (item.host !== undefined) out = `${out}@${item.host}`;
751
+ return out;
752
+ }
753
+
754
+ /** Queries the raw `ChannelMembers` rows for one channel (by lowercased key). */
755
+ async function queryChannelMembersRows(
756
+ dynamo: DynamoDBDocumentClient,
757
+ tables: TablesConfig,
758
+ key: string,
759
+ ): Promise<MarshalledChannelMember[]> {
760
+ const result = await dynamo.send(
761
+ new QueryCommand({
762
+ TableName: tables.ChannelMembers,
763
+ KeyConditionExpression: 'channelName = :k',
764
+ ExpressionAttributeValues: { ':k': key },
765
+ }),
679
766
  );
767
+ return (result.Items ?? []) as unknown as MarshalledChannelMember[];
680
768
  }
681
769
 
682
770
  // ---------------------------------------------------------------------------
@@ -18,7 +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
- * • `Accounts` never stores plaintext credentials (hash only).
21
+ * • `Accounts` stores SASL PLAIN credentials as scrypt
22
+ * `HashedAccountCredential` rows (`{ account, algorithm, salt,
23
+ * hash }`, base64) — never plaintext. Loaded at Lambda cold start
24
+ * by `loadDynamoAccountStore` and seeded via
25
+ * `putAccountCredential`.
22
26
  * • `Connections.idleSince` is the DynamoDB TTL attribute; idle connections
23
27
  * are reaped automatically in addition to the explicit sweeper.
24
28
  */
@@ -31,6 +31,13 @@ export interface LambdaConfigEnv {
31
31
  TOPIC_LEN?: string;
32
32
  MAX_LIST_ENTRIES?: string;
33
33
  QUIT_MESSAGE?: string;
34
+ /**
35
+ * SASL PLAIN accounts. Newline-delimited `username:password` pairs
36
+ * (same convention as the CF adapter's `SASL_ACCOUNTS`). Parsed into
37
+ * the `saslAccounts` config field so {@link bindAccountStore} can seed
38
+ * an `InMemoryAccountStore` at boot.
39
+ */
40
+ SASL_ACCOUNTS?: string;
34
41
  }
35
42
 
36
43
  /**
@@ -84,9 +91,32 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
84
91
  if (env.QUIT_MESSAGE !== undefined) {
85
92
  input.quitMessage = env.QUIT_MESSAGE;
86
93
  }
94
+ if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
95
+ input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
96
+ }
87
97
  return input;
88
98
  }
89
99
 
100
+ /**
101
+ * Parses a newline-delimited `username:password` string into the config's
102
+ * `saslAccounts` shape. Malformed entries (missing colon, empty fields)
103
+ * are skipped so a trailing newline or partial edit does not break boot.
104
+ */
105
+ function parseSaslAccountsLines(raw: string): Array<{ username: string; password: string }> {
106
+ const out: Array<{ username: string; password: string }> = [];
107
+ for (const line of raw.split('\n')) {
108
+ const trimmed = line.trim();
109
+ if (trimmed.length === 0) continue;
110
+ const sep = trimmed.indexOf(':');
111
+ if (sep <= 0) continue;
112
+ const username = trimmed.slice(0, sep);
113
+ const password = trimmed.slice(sep + 1);
114
+ if (username.length === 0 || password.length === 0) continue;
115
+ out.push({ username, password });
116
+ }
117
+ return out;
118
+ }
119
+
90
120
  /**
91
121
  * Loads and validates the server config from a Lambda env. Throws a
92
122
  * readable `Error` listing every invalid field on failure.
@@ -0,0 +1,156 @@
1
+ /**
2
+ * DynamoDB-backed {@link AccountStore} primitives for the AWS adapter.
3
+ *
4
+ * The `Accounts` DynamoDB table stores SASL PLAIN credentials as
5
+ * **scrypt hashes** (never plaintext). Each row is a flat
6
+ * {@link HashedAccountCredential} (`{ account, algorithm, salt, hash }`).
7
+ *
8
+ * Two-phase construction keeps the `AccountStore` port synchronous:
9
+ * 1. {@link loadDynamoAccountStore} (async) scans the table at Lambda
10
+ * cold start and returns a fully-populated `DynamoAccountStore`.
11
+ * 2. `DynamoAccountStore(entries)` (sync constructor) takes the
12
+ * pre-loaded entries. The class itself has zero DynamoDB
13
+ * coupling — it works equally well with entries from any source.
14
+ *
15
+ * This mirrors the pre-load pattern documented in
16
+ * `irc-core/src/ports.ts` and used by `MotdProvider` / `MessageStore`:
17
+ * adapters that need an async backend pre-load the data at boot into a
18
+ * synchronously-readable store.
19
+ */
20
+
21
+ import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
22
+ import type { ScanCommandOutput } from '@aws-sdk/client-dynamodb';
23
+ import { ScanCommand } from '@aws-sdk/lib-dynamodb';
24
+ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
25
+ import type { AccountStore, SaslPayload, SaslResult } from '@serverless-ircd/irc-core';
26
+
27
+ const SCRYPT_KEY_LEN = 64;
28
+ const SALT_LEN = 16;
29
+
30
+ /**
31
+ * A single SASL PLAIN credential stored as a scrypt hash.
32
+ *
33
+ * Every field is a DynamoDB attribute — this object IS the table row
34
+ * (plus the PK `account`). `algorithm` is retained so future migrations
35
+ * to Argon2/bcrypt can be detected and handled gracefully.
36
+ */
37
+ export interface HashedAccountCredential {
38
+ readonly account: string;
39
+ readonly algorithm: 'scrypt';
40
+ readonly salt: string;
41
+ readonly hash: string;
42
+ }
43
+
44
+ /**
45
+ * Hashes a plaintext password into a {@link HashedAccountCredential}
46
+ * suitable for writing to the `Accounts` table via `putAccountCredential`.
47
+ *
48
+ * Uses `scryptSync` (memory-hard, GPU-resistant) with a random salt.
49
+ * Pass `{ salt }` for deterministic test scenarios; pass `{ keyLen }` to
50
+ * override the default 64-byte derived-key length.
51
+ */
52
+ export function hashAccountCredential(
53
+ username: string,
54
+ password: string,
55
+ opts?: { salt?: Buffer; keyLen?: number },
56
+ ): HashedAccountCredential {
57
+ const salt = opts?.salt ?? randomBytes(SALT_LEN);
58
+ const keyLen = opts?.keyLen ?? SCRYPT_KEY_LEN;
59
+ const hash = scryptSync(password, salt, keyLen);
60
+ return {
61
+ account: username,
62
+ algorithm: 'scrypt',
63
+ salt: salt.toString('base64'),
64
+ hash: hash.toString('base64'),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Verifies a plaintext password against a {@link HashedAccountCredential}
70
+ * using `timingSafeEqual` (no short-circuit on first mismatched byte).
71
+ *
72
+ * Returns `false` for malformed entries rather than throwing — a corrupt
73
+ * row should not crash the SASL exchange.
74
+ */
75
+ export function verifyHashedPassword(password: string, entry: HashedAccountCredential): boolean {
76
+ if (entry.algorithm !== 'scrypt') return false;
77
+ let salt: Buffer;
78
+ let expected: Buffer;
79
+ try {
80
+ salt = Buffer.from(entry.salt, 'base64');
81
+ expected = Buffer.from(entry.hash, 'base64');
82
+ } catch {
83
+ return false;
84
+ }
85
+ if (expected.length === 0) return false;
86
+ const computed = scryptSync(password, salt, expected.length);
87
+ return computed.length === expected.length && timingSafeEqual(computed, expected);
88
+ }
89
+
90
+ /**
91
+ * Synchronous {@link AccountStore} backed by pre-loaded hashed
92
+ * credentials. Construct with entries from {@link loadDynamoAccountStore}
93
+ * (or {@link hashAccountCredential} for unit tests).
94
+ */
95
+ export class DynamoAccountStore implements AccountStore {
96
+ private readonly entries: ReadonlyMap<string, HashedAccountCredential>;
97
+
98
+ constructor(entries: ReadonlyArray<HashedAccountCredential>) {
99
+ this.entries = new Map(entries.map((e) => [e.account, e]));
100
+ }
101
+
102
+ verify(mech: string, payload: SaslPayload): SaslResult {
103
+ if (mech.toUpperCase() !== 'PLAIN' || payload.kind !== 'PLAIN') {
104
+ return { ok: false, reason: `unsupported mechanism: ${mech}` };
105
+ }
106
+ const entry = this.entries.get(payload.username);
107
+ if (entry === undefined) {
108
+ return { ok: false, reason: 'invalid credentials' };
109
+ }
110
+ if (!verifyHashedPassword(payload.password, entry)) {
111
+ return { ok: false, reason: 'invalid credentials' };
112
+ }
113
+ return { ok: true, account: payload.username };
114
+ }
115
+
116
+ /** Number of loaded accounts (diagnostics / logging). */
117
+ get size(): number {
118
+ return this.entries.size;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Scans the `Accounts` table and returns a fully-populated
124
+ * {@link DynamoAccountStore}, or `undefined` when the table has no rows.
125
+ *
126
+ * Handles pagination (`LastEvaluatedKey`) so tables larger than the 1 MB
127
+ * scan page are fully loaded. Rows missing required attributes are
128
+ * silently skipped.
129
+ */
130
+ export async function loadDynamoAccountStore(
131
+ docClient: DynamoDBDocumentClient,
132
+ tableName: string,
133
+ ): Promise<DynamoAccountStore | undefined> {
134
+ const entries: HashedAccountCredential[] = [];
135
+ let exclusiveStartKey: Record<string, unknown> | undefined = undefined;
136
+ do {
137
+ const result: ScanCommandOutput = await docClient.send(
138
+ new ScanCommand({
139
+ TableName: tableName,
140
+ ExclusiveStartKey: exclusiveStartKey,
141
+ }),
142
+ );
143
+ for (const item of result.Items ?? []) {
144
+ const account = typeof item.account === 'string' ? item.account : undefined;
145
+ const algorithm = typeof item.algorithm === 'string' ? item.algorithm : undefined;
146
+ const salt = typeof item.salt === 'string' ? item.salt : undefined;
147
+ const hash = typeof item.hash === 'string' ? item.hash : undefined;
148
+ if (account !== undefined && algorithm !== undefined && salt !== undefined && hash !== undefined) {
149
+ entries.push({ account, algorithm: algorithm as HashedAccountCredential['algorithm'], salt, hash });
150
+ }
151
+ }
152
+ exclusiveStartKey = result.LastEvaluatedKey;
153
+ } while (exclusiveStartKey !== undefined);
154
+ if (entries.length === 0) return undefined;
155
+ return new DynamoAccountStore(entries);
156
+ }
@@ -12,6 +12,7 @@ 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,
15
16
  type ChannelState,
16
17
  type Clock,
17
18
  type ConnectionState,
@@ -46,6 +47,12 @@ export interface DefaultParams {
46
47
  * behaviour of callers that have not opted in.
47
48
  */
48
49
  messages?: MessageStore;
50
+ /**
51
+ * SASL account verification source. When omitted the actor runs with
52
+ * `ctx.accounts` undefined so `AUTHENTICATE PLAIN` fails with `904`
53
+ * — preserves the behaviour of callers that have not opted in.
54
+ */
55
+ accounts?: AccountStore;
49
56
  managementApi: ApiGatewayManagementApi | PostToConnection | null;
50
57
  /** Injected for tests; defaults to {@link SystemClock}. */
51
58
  clock?: Clock;
@@ -116,6 +123,7 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
116
123
  ids,
117
124
  motd: params.motd,
118
125
  ...(params.messages !== undefined ? { messages: params.messages } : {}),
126
+ ...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
119
127
  });
120
128
 
121
129
  try {