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.
Files changed (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +398 -67
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +714 -105
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -1,95 +0,0 @@
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
- * The scrypt hashing helpers (`hashAccountCredential` /
21
- * `verifyHashedPassword` + the `HashedAccountCredential` type) live in
22
- * `irc-core` and are shared with the CF adapter's `D1AccountStore`. They
23
- * are re-exported below so existing call sites importing from this module
24
- * keep working.
25
- */
26
-
27
- import type { ScanCommandOutput } from '@aws-sdk/client-dynamodb';
28
- import { ScanCommand } from '@aws-sdk/lib-dynamodb';
29
- import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
30
- import {
31
- type HashedAccountCredential,
32
- HashedAccountStore,
33
- hashAccountCredential,
34
- verifyHashedPassword,
35
- } from '@serverless-ircd/irc-core';
36
-
37
- // Re-export so callers importing these from `@serverless-ircd/aws-adapter`
38
- // (the historical home of the hashing helpers) keep compiling.
39
- export { type HashedAccountCredential, hashAccountCredential, verifyHashedPassword };
40
-
41
- /**
42
- * Synchronous {@link AccountStore} backed by pre-loaded hashed credentials.
43
- *
44
- * Now a re-export of the shared `HashedAccountStore` from `irc-core` —
45
- * both adapters use one store implementation. The AWS-specific surface
46
- * (`loadDynamoAccountStore`) lives below; it diverges from CF only at the
47
- * scan boundary.
48
- */
49
- export { HashedAccountStore as DynamoAccountStore };
50
-
51
- /**
52
- * Scans the `Accounts` table and returns a fully-populated
53
- * {@link DynamoAccountStore}, or `undefined` when the table has no rows.
54
- *
55
- * Handles pagination (`LastEvaluatedKey`) so tables larger than the 1 MB
56
- * scan page are fully loaded. Rows missing required attributes are
57
- * silently skipped.
58
- */
59
- export async function loadDynamoAccountStore(
60
- docClient: DynamoDBDocumentClient,
61
- tableName: string,
62
- ): Promise<HashedAccountStore | undefined> {
63
- const entries: HashedAccountCredential[] = [];
64
- let exclusiveStartKey: Record<string, unknown> | undefined = undefined;
65
- do {
66
- const result: ScanCommandOutput = await docClient.send(
67
- new ScanCommand({
68
- TableName: tableName,
69
- ExclusiveStartKey: exclusiveStartKey,
70
- }),
71
- );
72
- for (const item of result.Items ?? []) {
73
- const account = typeof item.account === 'string' ? item.account : undefined;
74
- const algorithm = typeof item.algorithm === 'string' ? item.algorithm : undefined;
75
- const salt = typeof item.salt === 'string' ? item.salt : undefined;
76
- const hash = typeof item.hash === 'string' ? item.hash : undefined;
77
- if (
78
- account !== undefined &&
79
- algorithm !== undefined &&
80
- salt !== undefined &&
81
- hash !== undefined
82
- ) {
83
- entries.push({
84
- account,
85
- algorithm: algorithm as HashedAccountCredential['algorithm'],
86
- salt,
87
- hash,
88
- });
89
- }
90
- }
91
- exclusiveStartKey = result.LastEvaluatedKey;
92
- } while (exclusiveStartKey !== undefined);
93
- if (entries.length === 0) return undefined;
94
- return new HashedAccountStore(entries);
95
- }
@@ -1,223 +0,0 @@
1
- /**
2
- * DynamoDB-backed integration tests for the `Accounts` table path.
3
- * Exercises the real DynamoDB round-trip that pure unit
4
- * tests cannot: `putAccountCredential` writes a hashed row,
5
- * `loadDynamoAccountStore` scans it back, and `resolveAccountStore`
6
- * applies the table-then-config precedence.
7
- *
8
- * Gated on DynamoDB Local availability (same `DDB_AVAILABLE` flag as
9
- * `handlers.test.ts`).
10
- */
11
-
12
- import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
13
- import { PutCommand } from '@aws-sdk/lib-dynamodb';
14
- import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
15
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
16
- import {
17
- loadDynamoAccountStore,
18
- putAccountCredential,
19
- resolveAccountStore,
20
- } from '../src/account-store.js';
21
- import { TABLE_DEFS } from '../src/cdk-table-defs.js';
22
- import { createDynamoDocumentClient } from '../src/dynamo.js';
23
-
24
- const available = process.env.DDB_AVAILABLE === '1';
25
- const endpoint = process.env.DYNAMO_ENDPOINT;
26
-
27
- interface Fx {
28
- tableName: string;
29
- client: ReturnType<typeof createDynamoDocumentClient>;
30
- cleanup: () => Promise<void>;
31
- }
32
-
33
- let fx: Fx | null = null;
34
-
35
- function requireFx(): Fx {
36
- if (!fx) throw new Error('test fixture not initialized');
37
- return fx;
38
- }
39
-
40
- async function makeFx(): Promise<Fx> {
41
- if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
42
- const tableName = `acct-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
43
- const client = createDynamoDocumentClient({ endpoint });
44
- const props = TABLE_DEFS.Accounts;
45
- const pkName = props.partitionKey?.name;
46
- if (pkName === undefined) throw new Error('Accounts table missing partitionKey');
47
- await client.send(
48
- new CreateTableCommand({
49
- TableName: tableName,
50
- AttributeDefinitions: [{ AttributeName: pkName, AttributeType: 'S' }],
51
- KeySchema: [{ AttributeName: pkName, KeyType: 'HASH' }],
52
- BillingMode: 'PAY_PER_REQUEST',
53
- }),
54
- );
55
- return {
56
- tableName,
57
- client,
58
- cleanup: async () => {
59
- try {
60
- await client.send(new DeleteTableCommand({ TableName: tableName }));
61
- } catch {
62
- // Idempotent.
63
- }
64
- },
65
- };
66
- }
67
-
68
- describe.skipIf(!available)('Accounts table round-trip', () => {
69
- beforeEach(async () => {
70
- fx = await makeFx();
71
- });
72
- afterEach(async () => {
73
- if (fx) await fx.cleanup();
74
- fx = null;
75
- });
76
-
77
- it('putAccountCredential then loadDynamoAccountStore verifies the account', async () => {
78
- await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 's3cret');
79
- const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
80
- expect(store).toBeDefined();
81
- expect(
82
- store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 's3cret' }),
83
- ).toEqual({
84
- ok: true,
85
- account: 'alice',
86
- });
87
- });
88
-
89
- it('loadDynamoAccountStore returns undefined when the table has no rows', async () => {
90
- const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
91
- expect(store).toBeUndefined();
92
- });
93
-
94
- it('putAccountCredential writes multiple distinct accounts', async () => {
95
- await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'a1');
96
- await putAccountCredential(requireFx().client, requireFx().tableName, 'bob', 'b2');
97
- const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
98
- expect(store).toBeDefined();
99
- expect(store?.size).toBe(2);
100
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'a1' }).ok).toBe(
101
- true,
102
- );
103
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'b2' }).ok).toBe(
104
- true,
105
- );
106
- });
107
-
108
- it('putAccountCredential overwrites an existing account (re-seed)', async () => {
109
- await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'old');
110
- await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'new');
111
- const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
112
- expect(store).toBeDefined();
113
- expect(store?.size).toBe(1);
114
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'old' }).ok).toBe(
115
- false,
116
- );
117
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'new' }).ok).toBe(
118
- true,
119
- );
120
- });
121
-
122
- it('rejects a wrong password after round-trip', async () => {
123
- await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'right');
124
- const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
125
- expect(store).toBeDefined();
126
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
127
- false,
128
- );
129
- });
130
-
131
- it('silently skips rows with missing or wrong-typed credential fields', async () => {
132
- // The scan path must tolerate partially-malformed rows (legacy seeds,
133
- // schema drift) without throwing — it just skips them. Each branch of
134
- // the per-field `typeof === 'string'` guard is exercised by writing a
135
- // row where exactly one non-key field has the wrong type. (The
136
- // `account` field is the partition key, so DynamoDB itself enforces
137
- // its type at write time and the false branch is not exercisable
138
- // through the SDK; that branch is a defensive guard against
139
- // synthetically-shaped Scan results.)
140
- const client = requireFx().client;
141
- const table = requireFx().tableName;
142
- // Well-formed row — must survive.
143
- await putAccountCredential(client, table, 'good', 's3cret');
144
- // Row with non-string `algorithm`.
145
- await client.send(
146
- new PutCommand({
147
- TableName: table,
148
- Item: { account: 'noalgo', algorithm: 7, salt: 's', hash: 'h' },
149
- }),
150
- );
151
- // Row with non-string `salt`.
152
- await client.send(
153
- new PutCommand({
154
- TableName: table,
155
- Item: { account: 'nosalt', algorithm: 'SCRYPT-SHA-256', salt: false, hash: 'h' },
156
- }),
157
- );
158
- // Row with non-string `hash`.
159
- await client.send(
160
- new PutCommand({
161
- TableName: table,
162
- Item: { account: 'nohash', algorithm: 'SCRYPT-SHA-256', salt: 's', hash: null },
163
- }),
164
- );
165
- const store = await loadDynamoAccountStore(client, table);
166
- expect(store).toBeDefined();
167
- expect(store?.size).toBe(1);
168
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'good', password: 's3cret' }).ok).toBe(
169
- true,
170
- );
171
- });
172
- });
173
-
174
- describe.skipIf(!available)('resolveAccountStore precedence', () => {
175
- beforeEach(async () => {
176
- fx = await makeFx();
177
- });
178
- afterEach(async () => {
179
- if (fx) await fx.cleanup();
180
- fx = null;
181
- });
182
-
183
- it('returns a DynamoAccountStore when the table has rows (table wins)', async () => {
184
- await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'table-secret');
185
- const cfgWithSasl = makeTestServerConfig({
186
- saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
187
- });
188
- const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
189
- expect(store).toBeDefined();
190
- // Table account verifies; config account does NOT (table is authoritative).
191
- expect(
192
- store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'table-secret' }).ok,
193
- ).toBe(true);
194
- expect(
195
- store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
196
- ).toBe(false);
197
- });
198
-
199
- it('falls back to the config-seeded store when the table is empty', async () => {
200
- const cfgWithSasl = makeTestServerConfig({
201
- saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
202
- });
203
- const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
204
- expect(store).toBeDefined();
205
- expect(
206
- store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
207
- ).toBe(true);
208
- });
209
-
210
- it('returns undefined when the table is empty and no config accounts are set', async () => {
211
- const store = await resolveAccountStore(
212
- requireFx().client,
213
- requireFx().tableName,
214
- makeTestServerConfig(),
215
- );
216
- expect(store).toBeUndefined();
217
- });
218
-
219
- it('returns undefined when the table is empty and serverConfig is undefined', async () => {
220
- const store = await resolveAccountStore(requireFx().client, requireFx().tableName, undefined);
221
- expect(store).toBeUndefined();
222
- });
223
- });
@@ -1,276 +0,0 @@
1
- import { DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
2
- import type { ParsedServerConfig, SaslPayload } from '@serverless-ircd/irc-core';
3
- import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
4
- import { mockClient } from 'aws-sdk-client-mock';
5
- import { describe, expect, it } from 'vitest';
6
- import {
7
- DynamoAccountStore,
8
- type HashedAccountCredential,
9
- bindAccountStore,
10
- hashAccountCredential,
11
- putAccountCredential,
12
- resolveAccountStore,
13
- } from '../src/account-store';
14
-
15
- function plainPayload(username: string, password: string): SaslPayload {
16
- return { kind: 'PLAIN', username, password };
17
- }
18
-
19
- /** Builds a minimal parsed config with the supplied saslAccounts. */
20
- function configWith(accounts: Array<{ username: string; password: string }>): ParsedServerConfig {
21
- return makeTestServerConfig({ saslAccounts: accounts });
22
- }
23
-
24
- // ---------------------------------------------------------------------------
25
- // bindAccountStore — config-seeded fallback
26
- // ---------------------------------------------------------------------------
27
-
28
- describe('bindAccountStore', () => {
29
- it('returns an AccountStore that accepts valid PLAIN creds', () => {
30
- const store = bindAccountStore(configWith([{ username: 'alice', password: 'secret' }]));
31
- expect(store).toBeDefined();
32
- const out = store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' });
33
- expect(out).toEqual({ ok: true, account: 'alice' });
34
- });
35
-
36
- it('returns undefined when the account list is empty', () => {
37
- expect(bindAccountStore(configWith([]))).toBeUndefined();
38
- });
39
-
40
- it('returns undefined when serverConfig is undefined', () => {
41
- expect(bindAccountStore(undefined)).toBeUndefined();
42
- });
43
-
44
- it('rejects wrong credentials', () => {
45
- const store = bindAccountStore(configWith([{ username: 'alice', password: 'secret' }]));
46
- expect(store).toBeDefined();
47
- expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
48
- false,
49
- );
50
- });
51
- });
52
-
53
- // ---------------------------------------------------------------------------
54
- // hashAccountCredential — pure scrypt hashing helper
55
- // ---------------------------------------------------------------------------
56
-
57
- describe('hashAccountCredential', () => {
58
- it('produces an entry keyed by the username with scrypt', () => {
59
- const entry = hashAccountCredential('alice', 'secret');
60
- expect(entry.account).toBe('alice');
61
- expect(entry.algorithm).toBe('scrypt');
62
- expect(entry.salt.length).toBeGreaterThan(0);
63
- expect(entry.hash.length).toBeGreaterThan(0);
64
- });
65
-
66
- it('never stores the plaintext password in any field', () => {
67
- const entry = hashAccountCredential('alice', 'hunter2-secret');
68
- expect(entry.account).not.toContain('hunter2');
69
- expect(entry.salt).not.toContain('hunter2');
70
- expect(entry.hash).not.toContain('hunter2');
71
- });
72
-
73
- it('produces different hashes for the same password under different salts', () => {
74
- const a = hashAccountCredential('alice', 'secret', { salt: Buffer.from('aaaaaaaaaaaaaaaa') });
75
- const b = hashAccountCredential('alice', 'secret', { salt: Buffer.from('bbbbbbbbbbbbbbbb') });
76
- expect(a.hash).not.toBe(b.hash);
77
- });
78
-
79
- it('is deterministic for a fixed salt', () => {
80
- const salt = Buffer.from('1234567890123456');
81
- const a = hashAccountCredential('alice', 'secret', { salt });
82
- const b = hashAccountCredential('alice', 'secret', { salt });
83
- expect(a.hash).toBe(b.hash);
84
- });
85
-
86
- it('honours a custom key length', () => {
87
- const entry = hashAccountCredential('alice', 'secret', { keyLen: 32 });
88
- // base64 of 32 bytes → ~44 chars; base64 of 64 bytes → ~88 chars
89
- expect(Buffer.from(entry.hash, 'base64').length).toBe(32);
90
- });
91
- });
92
-
93
- // ---------------------------------------------------------------------------
94
- // DynamoAccountStore — synchronous verify against pre-loaded hashed entries
95
- // ---------------------------------------------------------------------------
96
-
97
- describe('DynamoAccountStore', () => {
98
- it('accepts valid PLAIN credentials and returns the account name', () => {
99
- const entry = hashAccountCredential('alice', 'secret');
100
- const store = new DynamoAccountStore([entry]);
101
- expect(store.verify('PLAIN', plainPayload('alice', 'secret'))).toEqual({
102
- ok: true,
103
- account: 'alice',
104
- });
105
- });
106
-
107
- it('rejects a wrong password', () => {
108
- const entry = hashAccountCredential('alice', 'secret');
109
- const store = new DynamoAccountStore([entry]);
110
- expect(store.verify('PLAIN', plainPayload('alice', 'wrong')).ok).toBe(false);
111
- });
112
-
113
- it('rejects an unknown username', () => {
114
- const store = new DynamoAccountStore([hashAccountCredential('alice', 'secret')]);
115
- expect(store.verify('PLAIN', plainPayload('bob', 'secret')).ok).toBe(false);
116
- });
117
-
118
- it('rejects a non-PLAIN mechanism', () => {
119
- const store = new DynamoAccountStore([hashAccountCredential('alice', 'secret')]);
120
- expect(store.verify('EXTERNAL', plainPayload('alice', 'secret')).ok).toBe(false);
121
- });
122
-
123
- it('rejects a RAW payload', () => {
124
- const store = new DynamoAccountStore([hashAccountCredential('alice', 'secret')]);
125
- expect(store.verify('PLAIN', { kind: 'RAW', data: 'whatever' }).ok).toBe(false);
126
- });
127
-
128
- it('handles multiple loaded accounts', () => {
129
- const store = new DynamoAccountStore([
130
- hashAccountCredential('alice', 'a-secret'),
131
- hashAccountCredential('bob', 'b-secret'),
132
- ]);
133
- expect(store.verify('PLAIN', plainPayload('alice', 'a-secret'))).toEqual({
134
- ok: true,
135
- account: 'alice',
136
- });
137
- expect(store.verify('PLAIN', plainPayload('bob', 'b-secret'))).toEqual({
138
- ok: true,
139
- account: 'bob',
140
- });
141
- });
142
-
143
- it('fails every verify when constructed with no entries', () => {
144
- const store = new DynamoAccountStore([]);
145
- expect(store.verify('PLAIN', plainPayload('alice', 'secret')).ok).toBe(false);
146
- });
147
-
148
- it('round-trips an externally-constructed HashedAccountCredential', () => {
149
- const row: HashedAccountCredential = hashAccountCredential('carol', 'p@ss');
150
- const store = new DynamoAccountStore([row]);
151
- expect(store.verify('PLAIN', plainPayload('carol', 'p@ss'))).toEqual({
152
- ok: true,
153
- account: 'carol',
154
- });
155
- });
156
-
157
- it('reports the number of loaded entries via size', () => {
158
- const store = new DynamoAccountStore([
159
- hashAccountCredential('alice', 'a'),
160
- hashAccountCredential('bob', 'b'),
161
- ]);
162
- expect(store.size).toBe(2);
163
- });
164
- });
165
-
166
- // ---------------------------------------------------------------------------
167
- // resolveAccountStore — table-then-config precedence (mock-based)
168
- // ---------------------------------------------------------------------------
169
-
170
- describe('resolveAccountStore', () => {
171
- it('returns a DynamoAccountStore when the table has rows (table wins)', async () => {
172
- const entry = hashAccountCredential('alice', 's3cret');
173
- const ddb = mockClient(DynamoDBDocumentClient);
174
- ddb.on(ScanCommand).resolves({
175
- Items: [entry],
176
- });
177
-
178
- const fallback = bindAccountStore(configWith([{ username: 'envuser', password: 'env-pw' }]));
179
- const store = await resolveAccountStore(
180
- ddb as unknown as DynamoDBDocumentClient,
181
- 'Accounts',
182
- fallback === undefined
183
- ? undefined
184
- : makeTestServerConfig({ saslAccounts: [{ username: 'envuser', password: 'env-pw' }] }),
185
- );
186
- expect(store).toBeDefined();
187
- expect(store?.verify('PLAIN', plainPayload('alice', 's3cret')).ok).toBe(true);
188
- // Config account does NOT verify (table is authoritative).
189
- expect(store?.verify('PLAIN', plainPayload('envuser', 'env-pw')).ok).toBe(false);
190
- });
191
-
192
- it('falls back to config-seeded store when the table is empty', async () => {
193
- const ddb = mockClient(DynamoDBDocumentClient);
194
- ddb.on(ScanCommand).resolves({ Items: [] });
195
-
196
- const store = await resolveAccountStore(
197
- ddb as unknown as DynamoDBDocumentClient,
198
- 'Accounts',
199
- configWith([{ username: 'bob', password: 'pw' }]),
200
- );
201
- expect(store).toBeDefined();
202
- expect(store?.verify('PLAIN', plainPayload('bob', 'pw')).ok).toBe(true);
203
- });
204
-
205
- it('falls back to config-seeded store when the scan throws', async () => {
206
- const ddb = mockClient(DynamoDBDocumentClient);
207
- ddb.on(ScanCommand).rejects(new Error('ResourceNotFoundException'));
208
-
209
- const store = await resolveAccountStore(
210
- ddb as unknown as DynamoDBDocumentClient,
211
- 'Accounts',
212
- configWith([{ username: 'bob', password: 'pw' }]),
213
- );
214
- expect(store).toBeDefined();
215
- expect(store?.verify('PLAIN', plainPayload('bob', 'pw')).ok).toBe(true);
216
- });
217
-
218
- it('returns undefined when table is empty and no config accounts', async () => {
219
- const ddb = mockClient(DynamoDBDocumentClient);
220
- ddb.on(ScanCommand).resolves({ Items: [] });
221
-
222
- const store = await resolveAccountStore(
223
- ddb as unknown as DynamoDBDocumentClient,
224
- 'Accounts',
225
- undefined,
226
- );
227
- expect(store).toBeUndefined();
228
- });
229
- });
230
-
231
- // ---------------------------------------------------------------------------
232
- // putAccountCredential — CRUD/seed entry point (mock-based)
233
- // ---------------------------------------------------------------------------
234
-
235
- describe('putAccountCredential', () => {
236
- it('writes a hashed row to DynamoDB and returns the credential', async () => {
237
- const ddb = mockClient(DynamoDBDocumentClient);
238
- ddb.on(PutCommand).resolves({});
239
-
240
- const entry = await putAccountCredential(
241
- ddb as unknown as DynamoDBDocumentClient,
242
- 'Accounts',
243
- 'alice',
244
- 's3cret',
245
- );
246
- expect(entry.account).toBe('alice');
247
- expect(entry.algorithm).toBe('scrypt');
248
-
249
- const calls = ddb.commandCalls(PutCommand);
250
- expect(calls).toHaveLength(1);
251
- const item = calls[0]?.args[0].input.Item as HashedAccountCredential;
252
- expect(item.account).toBe('alice');
253
- expect(item.algorithm).toBe('scrypt');
254
- expect(item.salt).toBe(entry.salt);
255
- expect(item.hash).toBe(entry.hash);
256
- });
257
-
258
- it('the written row round-trips through DynamoAccountStore', async () => {
259
- const ddb = mockClient(DynamoDBDocumentClient);
260
- ddb.on(PutCommand).resolves({});
261
-
262
- const entry = await putAccountCredential(
263
- ddb as unknown as DynamoDBDocumentClient,
264
- 'Accounts',
265
- 'alice',
266
- 's3cret',
267
- );
268
-
269
- // Simulate reading the row back via DynamoAccountStore.
270
- const store = new DynamoAccountStore([entry]);
271
- expect(store.verify('PLAIN', plainPayload('alice', 's3cret'))).toEqual({
272
- ok: true,
273
- account: 'alice',
274
- });
275
- });
276
- });